16 KiB
| name | description | metadata | ||
|---|---|---|---|---|
| supabase-auth | Configuring PowerSync with Supabase — database publication setup, JWT signing keys, Cloud dashboard setup, self-hosted service.yaml config, fetchCredentials() implementation, and error codes |
|
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
- JWT Signing Key Types
- PowerSync Cloud Setup
- Self-Hosted Config
- fetchCredentials()
- 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:
-- 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):
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 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.
New JWT signing keys (recommended)
- Enable the Use Supabase Auth checkbox.
- Leave the Supabase JWT Secret field empty.
- 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)
- Enable the Use Supabase Auth checkbox.
- Copy your JWT Secret from Supabase → Project Settings → JWT.
- Paste it into the Supabase JWT Secret (Legacy) field.
- 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):
- Leave Use Supabase Auth unchecked.
- Add a JWKS URI, e.g.
http://localhost:54321/auth/v1/.well-known/jwks.json. - Add
authenticatedas an accepted JWT Audience. - Click Save and Deploy.
Skipping the
authenticatedaudience causesPSYNC_S2105errors — see Troubleshooting below.
Self-Hosted service.yaml Config
New JWT signing keys (recommended)
PowerSync auto-detects the Supabase project from the connection string:
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)
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: truealone will not work — it cannot auto-detect a local project from the connection string.supabase: true+supabase_jwt_secretwill not work — it registers an HS256 key, but local Supabase issues ES256 tokens with akidthat 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:
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.internalor the Supabase container name (notlocalhost) because this URI is resolved from inside the PowerSync Docker container. block_local_jwks: falseis required becausehost.docker.internalresolves 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:
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:
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):
client_auth:
jwks_uri: http://localhost:54321/auth/v1/.well-known/jwks.json
audience:
- authenticated
Do not combine
supabase: truewithjwks_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 and Kotlin.
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.
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.
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).
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:
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:
// build.gradle.kts
implementation("com.powersync:connector-supabase:$powersyncVersion")
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. |
| 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
- Follow all steps in the Supabase JWT migration guide, including the "Rotate to asymmetric JWTs" step. The migration is not complete without this step.
- 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_uripoints to the Supabase JWKS endpoint andauthenticatedis in the audience list.
- Have all users sign out and sign back in to receive tokens signed with the new keys.