--- 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://.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://.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: ` 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_) # 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 { 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 { 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 { 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://.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.