skills/powersync/references/supabase-auth.md

366 lines
16 KiB
Markdown

---
name: supabase-auth
description: Configuring PowerSync with Supabase — database publication setup, JWT signing keys, Cloud dashboard setup, self-hosted service.yaml config, fetchCredentials() implementation, and error codes
metadata:
tags: supabase, auth, jwt, jwks, client_auth, fetchCredentials, authentication, hs256, rs256, publication, replica-identity
---
# PowerSync + Supabase Auth
> **Load this when** using Supabase as the backend — covers database publication setup, JWT signing keys, fetchCredentials(), uploadData error handling, and Cloud/self-hosted auth config.
## Table of Contents
- [Supabase Database Setup](#supabase-database-setup)
- [JWT Signing Key Types](#jwt-signing-key-types)
- [PowerSync Cloud Setup](#powersync-cloud-setup)
- [Self-Hosted Config](#self-hosted-serviceyaml-config)
- [fetchCredentials()](#fetchcredentials--client-implementation)
- [Troubleshooting](#troubleshooting)
PowerSync verifies Supabase JWTs directly when connected to a Supabase-hosted Postgres database. This file covers everything needed to configure authentication end-to-end.
## Supabase Database Setup
Supabase already has logical replication enabled at the WAL level. You still need to create a publication so PowerSync knows which tables to replicate, and set `REPLICA IDENTITY FULL` on each table so that DELETE operations include the full row (required for PowerSync to sync deletes to clients).
Run this in the Supabase SQL Editor **after creating your tables**:
```sql
-- Create the PowerSync publication (required)
-- List every table PowerSync should replicate
CREATE PUBLICATION powersync FOR TABLE lists, todos;
```
When you add a new table that PowerSync should replicate, add it to the publication. To replicate all current and future tables automatically (simpler but less precise):
```sql
CREATE PUBLICATION powersync FOR ALL TABLES;
```
## JWT Signing Key Types
Supabase projects use one of two signing key types. **Check which your project uses** at [Project Settings → JWT](https://supabase.com/dashboard/project/_/settings/jwt) in the Supabase Dashboard before configuring PowerSync.
| Type | Algorithm | Notes |
|------|-----------|-------|
| **New JWT signing keys** | RS256 (asymmetric) | Recommended. PowerSync auto-detects from the connection string. |
| **Legacy JWT signing keys** | HS256 (symmetric) | Requires supplying the JWT secret to PowerSync. Consider migrating. |
---
## PowerSync Cloud Setup
Configure via the **Client Auth** section of your instance in the [PowerSync Dashboard](https://dashboard.powersync.com/).
### New JWT signing keys (recommended)
1. Enable the **Use Supabase Auth** checkbox.
2. Leave the **Supabase JWT Secret** field empty.
3. Click **Save and Deploy**.
PowerSync auto-detects your Supabase project from the database connection string and configures the JWKS URI (`https://<project-ref>.supabase.co/auth/v1/.well-known/jwks.json`) and JWT audience (`authenticated`) automatically.
### Legacy JWT signing keys (HS256)
1. Enable the **Use Supabase Auth** checkbox.
2. Copy your **JWT Secret** from Supabase → [Project Settings → JWT](https://supabase.com/dashboard/project/_/settings/jwt).
3. Paste it into the **Supabase JWT Secret (Legacy)** field.
4. Click **Save and Deploy**.
### Manual JWKS (non-standard connections)
Use this when PowerSync cannot auto-detect your Supabase project (self-hosted Supabase, local Docker, non-standard connection string):
1. Leave **Use Supabase Auth** unchecked.
2. Add a **JWKS URI**, e.g. `http://localhost:54321/auth/v1/.well-known/jwks.json`.
3. Add `authenticated` as an accepted **JWT Audience**.
4. Click **Save and Deploy**.
> Skipping the `authenticated` audience causes `PSYNC_S2105` errors — see Troubleshooting below.
---
## Self-Hosted `service.yaml` Config
### New JWT signing keys (recommended)
PowerSync auto-detects the Supabase project from the connection string:
```yaml
client_auth:
supabase: true
```
PowerSync automatically sets:
- JWKS URI: `https://<project-ref>.supabase.co/auth/v1/.well-known/jwks.json`
- Audience: `authenticated`
### Legacy JWT signing keys (HS256)
```yaml
client_auth:
supabase: true
supabase_jwt_secret: !env SUPABASE_JWT_SECRET
```
Get the secret from Supabase → Project Settings → JWT. Use `!env` to avoid hardcoding secrets.
### Local Supabase (`supabase start`)
**IMPORTANT:** Local Supabase (via `supabase start`) uses **ES256 asymmetric JWT signing keys**, not the legacy HS256 shared secret. This means:
- `supabase: true` alone **will not work** — it cannot auto-detect a local project from the connection string.
- `supabase: true` + `supabase_jwt_secret` **will not work** — it registers an HS256 key, but local Supabase issues ES256 tokens with a `kid` that doesn't match.
- You **must** use manual JWKS pointing to the local Supabase JWKS endpoint.
The error you'll see if misconfigured:
```
PSYNC_S2101: Could not find an appropriate key in the keystore. The key is missing or no key matched the token KID
```
With details showing: `Known keys: <kid: *, kty: oct, alg: HS256>` but the token has `alg: ES256` with a specific `kid`.
**Correct config for local Supabase:**
```yaml
client_auth:
# Use host.docker.internal to reach the host machine from inside the PowerSync Docker container.
# Alternatively, use the Supabase Kong container name (e.g. supabase_kong_<project-id>)
# if both are on the same Docker network.
jwks_uri: http://host.docker.internal:54321/auth/v1/.well-known/jwks.json
audience:
- authenticated
block_local_jwks: false
```
Key details:
- Use `host.docker.internal` or the Supabase container name (not `localhost`) because this URI is resolved **from inside the PowerSync Docker container**.
- `block_local_jwks: false` is required because `host.docker.internal` resolves to a local/private IP, which PowerSync blocks by default.
- The well-known local Supabase JWT secret (`super-secret-jwt-token-with-at-least-32-characters-long`) is **not used** for token signing in newer Supabase versions — it's only used for the service role key and anon key.
**SSL for local Supabase Postgres:** Local Supabase does not support SSL. You **must** set `sslmode: disable` on the replication connection in `service.yaml`. The `sslmode=disable` query string in the URI alone does not work — pgwire ignores it. Use the YAML key instead:
```yaml
replication:
connections:
- type: postgresql
uri: postgresql://postgres:postgres@host.docker.internal:54322/postgres
sslmode: disable
```
Without this you will see: `Replication error postgres does not support ssl`.
You can verify your local Supabase is using ES256 by checking:
```bash
curl -s http://127.0.0.1:54321/auth/v1/.well-known/jwks.json
# Returns: {"keys":[{"alg":"ES256","crv":"P-256","kty":"EC",...}]}
```
### Manual JWKS (other non-standard connections)
Use when `supabase: true` cannot auto-detect the project (e.g. self-hosted Supabase, custom auth proxy):
```yaml
client_auth:
jwks_uri: http://localhost:54321/auth/v1/.well-known/jwks.json
audience:
- authenticated
```
> Do **not** combine `supabase: true` with `jwks_uri`. Use one or the other.
---
## `fetchCredentials()` — Client Implementation
**Prerequisite:** `fetchCredentials()` requires an active Supabase auth session. PowerSync calls it automatically whenever a token is needed, but if no session exists (user not signed in), it will throw and sync will not start. **You must sign the user in before calling `db.connect()`.**
- If your app requires explicit sign-in (email/password, OAuth, magic link), connect PowerSync only after the sign-in completes.
- If anonymous access is acceptable, use the anonymous sign-in pattern below.
- If anonymous auth is disabled on your Supabase project, there is no silent fallback — the agent must gate `db.connect()` behind an explicit auth flow.
`fetchCredentials()` in your backend connector should return the Supabase session JWT. The examples below use the JS Supabase client; equivalent patterns exist for [Dart](https://github.com/powersync-ja/powersync.dart/blob/9ef224175c8969f5602c140bcec6dd8296c31260/demos/supabase-todolist/lib/powersync.dart#L38) and [Kotlin](https://github.com/powersync-ja/powersync-kotlin/blob/main/connectors/supabase/src/commonMain/kotlin/com/powersync/connector/supabase/SupabaseConnector.kt).
### Standard Supabase Auth (JS/TS)
Use this when users sign in explicitly (email, OAuth, magic link). Call `db.connect(connector)` only after `supabase.auth.signIn*` succeeds.
```ts
import { createClient } from '@supabase/supabase-js';
import type { PowerSyncBackendConnector, PowerSyncCredentials } from '@powersync/web'; // or @powersync/react-native
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
export const connector: PowerSyncBackendConnector = {
async fetchCredentials(): Promise<PowerSyncCredentials> {
const { data: { session }, error } = await supabase.auth.getSession();
if (error || !session) throw error ?? new Error('No session');
return {
endpoint: POWERSYNC_URL,
token: session.access_token,
expiresAt: new Date(session.expires_at! * 1000),
};
},
// ...uploadData
};
```
### Anonymous Sign-In (JS/TS)
Use this when you want sync to work without an explicit sign-in step. Requires **anonymous sign-ins to be enabled** in Supabase (Dashboard → Authentication → Providers → Anonymous). If disabled, `signInAnonymously()` returns an error and sync fails silently.
```ts
async fetchCredentials(): Promise<PowerSyncCredentials> {
let { data: { session } } = await supabase.auth.getSession();
if (!session) {
const { data, error } = await supabase.auth.signInAnonymously();
if (error) throw error; // Will throw if anonymous auth is disabled
session = data.session!;
}
return {
endpoint: POWERSYNC_URL,
token: session.access_token,
expiresAt: new Date(session.expires_at! * 1000),
};
},
```
`fetchCredentials` is called automatically on reconnect — always return a fresh token, never a cached one.
### `uploadData()` — Writing Changes Back to Supabase
For Supabase backends, `uploadData` writes client-side changes directly to Supabase. **`transaction.complete()` is mandatory** — without it the upload queue stalls permanently.
#### Error handling strategy
| Error type | What to do | Why |
|-----------|-----------|-----|
| Network / 5xx (transient) | `throw error` — do not call `transaction.complete()` | PowerSync retries with backoff |
| 4xx / RLS violation (permanent) | Call `transaction.complete()`, log the error | 4xx blocks the queue forever; better to skip and log than halt all future writes |
| Validation error | Call `transaction.complete()`, surface via a synced error table | Data errors are permanent; retrying won't fix them |
The Supabase JS client returns errors as `{ error: PostgrestError }` rather than throwing HTTP status codes — check `error.code` or `error.message` to distinguish permanent failures (constraint violations, RLS denials) from transient ones. Supabase RLS errors return `{ code: '42501' }` (PostgreSQL insufficient_privilege).
```ts
import type { AbstractPowerSyncDatabase, PowerSyncBackendConnector, UpdateType } from '@powersync/web';
export const connector: PowerSyncBackendConnector = {
async fetchCredentials() { /* ... see above ... */ },
async uploadData(database: AbstractPowerSyncDatabase): Promise<void> {
const transaction = await database.getNextCrudTransaction();
if (!transaction) return;
try {
for (const op of transaction.crud) {
const { op: opType, table, opData, id } = op;
let result: { error: any };
if (opType === UpdateType.PUT) {
result = await supabase.from(table).upsert({ ...opData, id });
} else if (opType === UpdateType.PATCH) {
result = await supabase.from(table).update(opData).eq('id', id);
} else {
result = await supabase.from(table).delete().eq('id', id);
}
if (result.error) throw result.error;
}
await transaction.complete(); // REQUIRED — advances the queue
} catch (error: any) {
// Permanent failures (RLS violation, constraint error, 4xx-equivalent):
// complete the transaction so the queue can advance. Log for debugging.
const isPermanent = error?.code === '42501' || error?.status === 400;
if (isPermanent) {
console.error('Permanent upload error, skipping:', error);
await transaction.complete();
return;
}
// Transient failures: throw so PowerSync retries with backoff.
throw error;
}
}
};
```
**Important:** RLS policies on your Supabase tables must allow the authenticated user to write their own rows. Ensure `INSERT`/`UPDATE`/`DELETE` policies exist — `SELECT`-only policies silently block all writes.
### Getting the PowerSync Instance URL
See `references/powersync-cli.md` § "Getting POWERSYNC_URL" — the instance ID is printed by `powersync link cloud --create` and the URL pattern is `https://<instance-id>.powersync.journeyapps.com`. Write it to `.env` before writing app code.
For self-hosted, the URL is whatever hostname your PowerSync Docker service is exposed on (e.g. `http://localhost:8080`).
---
## `auth.user_id()` in Sync Streams
`auth.user_id()` returns the Supabase user's UUID (the `sub` claim from the JWT). Use it to scope sync queries per user:
```yaml
streams:
my_todos:
auto_subscribe: true
query: SELECT * FROM todos WHERE user_id = auth.user_id()
```
For Sync Rules (legacy), use `request.user_id()` instead.
---
## Kotlin: Built-in Supabase Connector
The Kotlin SDK includes a first-party Supabase connector that handles `fetchCredentials` and session management automatically:
```kotlin
// build.gradle.kts
implementation("com.powersync:connector-supabase:$powersyncVersion")
```
```kotlin
val connector = SupabaseConnector(
supabaseUrl = "https://your-project.supabase.co",
supabaseKey = "your-anon-key",
powerSyncEndpoint = "https://your-instance.powersync.journeyapps.com",
)
```
---
## Troubleshooting
### `PSYNC_S2101` — Could not find an appropriate key in the keystore
PowerSync cannot verify the JWT signature. Check the error logs for `Known keys` and `tokenDetails` to diagnose the mismatch.
| Cause | Symptom | Solution |
|-------|---------|---------|
| **Local Supabase with `supabase_jwt_secret`** | Known keys show `HS256` but token uses `ES256` with a specific `kid` | Local Supabase uses ES256 asymmetric keys. Switch to manual JWKS config — see "Local Supabase" section above. |
| Incomplete Supabase key migration | Token `alg` doesn't match keystore | Complete the "Rotate to asymmetric JWTs" step in the [Supabase migration guide](https://supabase.com/blog/jwt-signing-keys#start-using-asymmetric-jwts-today). |
| Stale tokens after migration | Old tokens fail, new logins work | Have users sign out and back in to receive new tokens. |
| Auto-detection failed | `supabase: true` but no keys registered | PowerSync couldn't detect your Supabase project from the connection string. Use manual JWKS config. |
| Wrong JWT secret | HS256 verification fails | For legacy HS256 keys, verify the secret matches Supabase → Project Settings → JWT. |
| `block_local_jwks` blocking JWKS fetch | JWKS URI resolves to private IP, keys never fetched | Set `block_local_jwks: false` for local development. |
After any `service.yaml` auth change, restart the service to pick it up: `powersync docker reset` (self-hosted) or `powersync deploy service-config` (Cloud).
### `PSYNC_S2105` — JWT payload is missing a required claim "aud"
Using manual JWKS config without specifying an audience. Add `authenticated` to the audience list (Cloud dashboard or `audience: [authenticated]` in `service.yaml`).
### Auto-detection warning
If you see:
```
Supabase Auth is enabled, but no Supabase connection string found. Skipping Supabase JWKS URL configuration.
```
PowerSync couldn't detect your project from the connection string. Switch to manual JWKS configuration.
---
## Migrating from Legacy to New JWT Signing Keys
1. Follow **all steps** in the [Supabase JWT migration guide](https://supabase.com/blog/jwt-signing-keys#start-using-asymmetric-jwts-today), including the **"Rotate to asymmetric JWTs"** step. The migration is not complete without this step.
2. Update PowerSync config:
- **Cloud / self-hosted with standard connection**: No change needed — PowerSync auto-detects the new JWKS. Remove any previously set legacy JWT secret.
- **Manual JWKS**: Ensure `jwks_uri` points to the Supabase JWKS endpoint and `authenticated` is in the audience list.
3. Have all users sign out and sign back in to receive tokens signed with the new keys.