Supabase Auth end to end: tokens, trust, and what you can take in-house
Authentication on Supabase is one Postgres schema (auth), one issuer (GoTrue) writing to it, and several verifiers (PostgREST, GoTrue itself, Storage, Realtime, Edge Functions) reading a key set. Everything else in this reference - refresh races, third-party issuers, identity moves, lockdown levers, running your own GoTrue - is a consequence of that shape. This doc puts the measured behaviour of each piece in one place and links out to the doc that measured it.
Every row marked measured was run against a real project by the supabase-lab harness between 2026-07-09 and 2026-09-02, each project created and destroyed for the run; the experiment and module id is named per row and the run records are public. Where two runs disagree the later one is stated and the earlier one is noted. Documented rows cite the vendor page and were not run.
TL;DR:
- Two keys verify, one signs. A new project holds an ES256 key
in_useand the legacy HS256 secretpreviously_used. GoTrue signs with the ES256 key; both still verify. The HS256 secret is readable from the Management API, so anyone with a PAT can mint tokens PostgREST accepts, with zero GoTrue involvement. - PostgREST is the strict verifier. It rejects
iatmore than 30 s in the future (401 PGRST303), an unknown key (401 PGRST301), and an HS256 token that carries anykidat all - while the managed GoTrue accepts that same token. Two verifiers, two rules. - Refresh tokens live with the issuer. A trusting project verifies a token it did not mint but answers
400 refresh_token_not_foundto its refresh token; losing the issuer costs one access-token lifetime (3600 s) before nobody can renew. - A consumer caches a third-party key set and did not re-resolve within the windows measured. No re-resolution across 37 minutes in one run and 20 minutes in another, so a hub key rotation strands every spoke for at least that long and revocation does not revoke at a consumer.
- RLS reads a GUC, not a header.
auth.uid()readsrequest.jwt.claims; set it over the wire and per-user RLS works with no PostgREST and no GoTrue. The transaction pooler leaks a bareSETacross clients; useset_config(..., true)inside a transaction. - Identity is portable by SQL, not by API. Five
auth.*tables copied in FK order move a tenant with its existing refresh token and its MFA factor; the admin API can mint a user with a bcrypt hash but cannot carry a session. - A GoTrue you run yourself works against a managed project as the
postgresrole withsearch_path=auth, shares users and refresh tokens with the managed one, and is trusted by PostgREST as long as the legacy HS256 signing key stayspreviously_used. Revoking that signing key kills its tokens in 3-6 s (3 s, 6 s and 4 s on three projects), and the legacy anon and service_role API keys with them. Give it its own ES256 key, publish the public half from an Edge Function on the project and register that URL as third-party auth, and its tokens are accepted by PostgREST 4 s after the registration and survive that revoke.
Topology
Section titled “Topology”Which shape do I pick
Section titled “Which shape do I pick”| Goal | Shape | Where it is measured |
|---|---|---|
| Ordinary app, one project | Managed GoTrue, verify at the edge with cached JWKS (~0 ms) or round-trip getUser() (~207 ms) when revocation matters | Cloudflare Workers + Supabase |
| Many tenants, one project | Managed GoTrue, tenant_id in app_metadata set by the admin API, RLS keyed on it | Running many tenants on one project |
| Corporate identity in front of the Data API | Third-party auth with the IdP’s issuer, RLS keyed on iss, signups disabled | Put an IAP over the Data API |
| Keep Supabase Auth, drop PostgREST | Verify the GoTrue JWT in your backend, set request.jwt.claims over the wire | RLS without Supabase Auth |
| Move a tenant between projects with no re-login | Copy the five auth.* tables in FK order, resync the sequence, retire the source identity | Promoting a tenant |
| Bring users from a PBKDF2 system | Passwordless import plus a local verifier in an Edge Function | Migrating PBKDF2 hashes |
| Run the auth server yourself on a managed project | Self-hosted GoTrue as postgres with search_path=auth; sign with your own ES256 key registered through third-party auth (JWKS from an Edge Function), keep the platform ES256 public key and the legacy HS256 secret as verify-only JWKs | this doc, Taking Auth in-house |
The token: keys and verifiers
Section titled “The token: keys and verifiers”A fresh project bootstraps two signing keys: ES256 in_use and the legacy HS256 secret previously_used.1 GoTrue mints with the in_use key and stamps its id as kid; the JWKS at /auth/v1/.well-known/jwks.json advertises the ES256 public key with key_ops: ["verify"]. Statuses are standby, in_use, previously_used, revoked, managed at GET|POST|PATCH /v1/projects/{ref}/config/auth/signing-keys on the Management API; the project’s own /auth/v1/admin/signing-keys is a hard 404 (measured, key-rotation R01).
The HS256 secret is not gone. GET /v1/projects/{ref}/postgrest returns jwt_secret, and an HS256 token minted locally with it reads the live API with 200 and no GoTrue involved (edge-resilience W07). It is the break-glass path during an Auth outage and the crown jewel a PAT holder carries; rotate it after any use, because the Management API read is auditable and the secret does not expire on its own. PATCH /config/auth {jwt_secret} returns 200 and changes nothing (W01).
| What the verifier checks | PostgREST | Managed GoTrue | Measured |
|---|---|---|---|
iat skew | +30 s accepted, +31 s 401 PGRST303 | not probed | W01, two runs |
| expired token | 401 PGRST303 | 403 bad_jwt | W01; self-hosted-auth SH05 |
| unknown key | 401 PGRST301 | 403 bad_jwt | W01; SH02f |
HS256 token carrying a kid | 401 PGRST301, for an arbitrary kid and for the platform’s own HS256 key id | 200 | SH04, three projects |
HS256 token with no kid | 200 | 200 | SH02d, SH04c |
jwt_exp change | n/a - exp is minted by the issuer | honours it about 6.5 s after the PATCH reads back | W03 |
The kid row is the one nobody documents. PostgREST looks a kid up in its key set and the legacy HS256 secret is not addressable by one; GoTrue tries the keys of the algorithm regardless. Any signer that builds HS256 tokens from the legacy secret must leave the header without a kid.
PGRST301 means the key: unresolved JWKS, wrong kid, or a symmetric token where an asymmetric one was expected. A verified token that lacks claims fails later, as [] or a permission error, never as 301. PGRST303 means the claim: iat or exp. A 303 spike while /auth/v1/user still answers 200 is a clock-skew alert; a 303 on its own can be an ordinary expired token, and the raw 401 count is ambiguous.2
Sessions and refresh
Section titled “Sessions and refresh”GoTrue rotates the refresh token on every use and keeps the family in auth.refresh_tokens and auth.sessions. The rules, measured on hosted and on a local GoTrue v2.195.0 with identical results (auth-refresh-race):
- Reuse of the direct parent of the active token is tolerated with no time limit and returns the active token (3 s, 15 s, 45 s, 130 s after rotation).
- Reuse of an older generation is refused
400 refresh_token_already_usedonce past the reuse interval (10 s default, measured 12-15 s) and GoTrue marks the family revoked. In the same run, the token that was current at the moment of the refused reuse still refreshed successfully after that mark: the family revocation did not invalidate the live token. - Five concurrent refresh calls through the gotrue-dart client collapsed to a single network request and succeeded (auth-refresh-race, both client versions); two concurrent raw HTTP refreshes of one token sent straight to GoTrue both returned 200 (edge-resilience W08). Naive multi-tab concurrency does not reproduce the intermittent-401 mode.
PATCH security_refresh_token_reuse_intervalto 0 was accepted and read back 0 while the running service kept the 10 s behaviour. Config acceptance is not runtime effect.
The client defect that did reproduce is gotrue-dart 2.21.0 (pinned by supabase_flutter 2.14.0): a stale grandparent refresh answered refresh_token_already_used and the client destroyed the current, still-valid session. Fixed in gotrue-dart 2.22.0, first shipped in supabase_flutter 2.15.0, where the rejection is absorbed. Apps on supabase_flutter 2.15.0 or later (gotrue-dart 2.22.0 or later) that still sign out are hitting one of the by-design paths: expired session plus stale token, cross-isolate refresh, signOut() on a 401, or a custom storage resurrecting old tokens.2
Refresh tokens are the issuer’s. A project that trusts another’s tokens through third-party auth verifies them but answers 400 {"code":400,"error_code":"refresh_token_not_found","msg":"Invalid Refresh Token: Refresh Token Not Found"} to the refresh token (cross-project-auth X03). After the issuing project was deleted, reads at the trusting project with a live token kept returning 200 until it expired; refresh at the deleted issuer’s /auth/v1/token answered 410 Gone (tenant-promotion Part 3). The blast radius of losing the issuer is one access-token lifetime, 3600 s by default.
External issuers: third-party auth
Section titled “External issuers: third-party auth”POST /v1/projects/{ref}/config/auth/third-party-auth takes one of three shapes. oidc_issuer_url and jwks_url return 201 and resolve on the create response, 59-249 ms, to identical key material. custom_jwks returns 201, echoes the key back, and never resolves: resolved_at was still null past 92 s on two fresh projects and a token was refused PGRST301 for 7 minutes 32 seconds until the shape was swapped (cross-project-auth X01). The response type is custom for all three shapes and does not tell you which one created the integration.
Trust creation and removal are prompt and not synchronous. With no trust the token is refused 401 PGRST301; about 1.1 s after the create it is accepted; about 0.6 s after the delete it is refused again, byte-identical token throughout (X02). A first-time issuer whose kid PostgREST has never seen took about 30 s of PGRST301 before acceptance in the edge-resilience runs (W01, W05); a previously seen key set warms in about 300 ms. The 1.1 s acceptance figure is from X02 (2026-08-03, issuer: another Supabase project); the 30 s cold figure is from W01 and W05 (2026-08-15, issuers: a lab ES256 issuer in W01 and a Supabase project in W05). The runs do not reconcile the two; rehearse a cutover rather than assume either.
The consumer’s cache is the hazard. After the issuing project rotated its key, the trusting project’s cached kid set never changed across 282 probes in 37 minutes (2026-08-04) and 116 probes in 20 minutes (2026-08-25), and new-key tokens were refused the whole window. A fresh integration created while the issuer’s JWKS demonstrably advertised the new key still served the stale set (key-rotation R02, R03). The signing-keys docs say revocation of an asymmetric key is automatic via the discovery endpoint;1 that holds for the issuing project and not for a consumer inside its cache window, which honoured a revoked key for 15 minutes in the 2026-08-03 run. Whether a consumer ever re-resolves, and what the cache mechanism is, remains open.
A real Cloudflare Access for SaaS OIDC issuer registers with 201 and resolves immediately; a policy on (auth.jwt() ->> 'iss') then admits its tokens and refuses the anon key and GoTrue users (iap-lockdown L10). Third-party auth gates the Data API only; Auth, Storage, Realtime and Edge Functions keep their own endpoints. The managed Auth endpoint refused a third-party token outright (403 bad_jwt, one issuer, SH06); whether Storage or Realtime verify one is still open, because the single Storage probe that was run (GET /storage/v1/bucket) answers the anon key too.
Claims and RLS
Section titled “Claims and RLS”PostgREST verifies the token, switches to the role claim’s database role, and sets the claims as the request.jwt.claims GUC. auth.uid() reads sub from that GUC. Postgres enforces the policy for any role without BYPASSRLS, on any connection. That is the whole mechanism, and it is why the HTTP layer is optional: set the GUC over the wire and the same policies work with no PostgREST (rls-wire-claims C01, C02).3
Measured rules for doing that:
- On managed Supabase
GRANT USAGE ON SCHEMA authto a custom role silently no-ops andauth.uid()errorspermission denied for schema auth. The working shape is a SECURITY DEFINER wrapper owned bypostgresthat the policy calls. - The session pooler (5432) resets a bare
SETon return. The transaction pooler (6543) leaks it to the next client. Claims belong inset_config('request.jwt.claims', $1, true)inside a transaction, never a bareSET, and never interpolated into SQL text. - A tampered
subis enforced exactly as written: the probe’s made-upsubmatched zero rows with no error, and nothing in the database noticed the substitution. The GUC is unprivileged; the connection credential is the security boundary. - Hyperdrive did not replay a claims-dependent query across users in the probe; the split-binding rule (a second config with caching disabled for claims-dependent queries) stays as the documented control.
Tenant claims: app_metadata is admin-only. A user’s own PUT /auth/v1/user with app_metadata answers 403 Updating app_metadata requires admin privileges (re-measured 2026-08-03 on two fresh projects; the 2026-07-09 placement proof saw the same call silently ignored), while the same call with data writes user_metadata and the next token carries it. POST /auth/v1/signup with app_metadata in the body succeeds and drops the field. Key the policy on app_metadata, never user_metadata.4
Policy shapes that bit in the lab:
| Shape | Result | Measured |
|---|---|---|
for all ... using (...) with no with check | Postgres reuses using as the check; cross-tenant insert and reassignment both refused | shared-tenancy Part 3, Postgres 17.10 |
with check (true) | tenant B’s row attributed to tenant A landed while every read test passed | tenant-consolidation |
PostgREST default return=representation | a permitted write is rolled back and reports 403 42501 because RETURNING is filtered by the SELECT policy; under return=minimal the same write is committed and reports 201 | tenant-consolidation |
| RLS enabled, no policy | 200 and [] - a blank app, not an error | tenant-consolidation |
plain VIEW over an RLS table | leaks all rows to anon; security_invoker=true returns 0 | iap-lockdown L08 |
| UPDATE policy | gates rows, not columns; a permissive policy plus table grant let anon overwrite a sensitive column (204); REVOKE UPDATE plus GRANT UPDATE (safe columns) answers 401 with SQLSTATE 42501 | security-lockdown S13 |
| two PERMISSIVE policies | OR together, so adding a second permissive policy widened what anon could read | iap-lockdown L08 |
REVOKE SELECT ... FROM anon | undone for new tables by pg_default_acl; ALTER DEFAULT PRIVILEGES for the postgres grantor fixes it, supabase_admin’s default ACL cannot be altered by the owner (42501) | iap-lockdown L08 |
The Management API query endpoint connects as postgres and sees every row; verifying that data landed says nothing about isolation.
Identity portability between projects
Section titled “Identity portability between projects”Copying one auth.users row to a project with no third-party integration lets that user log in there with a token whose iss is the new project (tenant-promotion Part 3). Copying auth.users, auth.identities, auth.sessions, auth.refresh_tokens and auth.mfa_factors in FK order, then setval on auth.refresh_tokens_id_seq, lets the user’s existing refresh token mint a session at the new project with zero password logins, and the TOTP factor arrives verified with the same secret producing a valid aal2 code (P01, P02). It is unsupported, it writes into auth, and it is the only way to carry a session: the admin API mints a user, not the refresh token that user is holding.
The sharp edges, each measured:
auth.users.confirmed_atandauth.identities.emailareGENERATED ALWAYS;insert ... select *fails428C9. Enumerate columns withis_generated = 'NEVER'; there were 34 onauth.users.auth.refresh_tokens.user_idischaracter varying, notuuid. Auuidpredicate errors, and an INSERT of zero rows succeeds, so a copy can report success having moved nothing.- Do not carry the source’s
auth.refresh_tokens.id; let the target assign it. A carried id collides withrefresh_tokens_pkeyon any target with prior auth activity, on the tenant’s next refresh rather than during the move. users_email_partial_keyisUNIQUE (email) WHERE (is_sso_user = false)over the raw column. The admin API normalises case and refuses a variant with422 email_exists; a SQL copy lands two rows for one person, and which row a login reaches is unstable across attempts. That is a cross-tenant exposure.- One duplicate in a bulk INSERT costs the whole statement.
DELETE /auth/v1/admin/users/{id}at the source refuses the password grant withinvalid_credentialsand the old refresh token withrefresh_token_not_foundwhile the destination carries on. The identity at the source retires; the tenant’s data rows at the source stay where they are (P03).
Through the admin API, POST /auth/v1/admin/users accepts a bcrypt password_hash as-is (the source’s $2a$ string) and the user logs in with the password they had, honours a supplied id, and sets app_metadata at creation (tenant-consolidation C03). GoTrue imports exactly three hash formats, bcrypt, Argon2 and Firebase scrypt, dispatched by prefix; a PBKDF2 hash answers 500 unexpected_failure because the string is handed to bcrypt.Cost(). The Password Verification Hook runs after valid is computed and cannot plug in a verifier. The working pattern for PBKDF2 sources is a passwordless import plus a local WebCrypto verifier in an Edge Function that migrates on the first correct login.5 The incident-resilience reference (class 9) and the resilience runbook stated the opposite about the admin API, citing the 2026-08-15 standby run (edge-resilience W09), which posted its backfill users without a password_hash and recorded the limitation as an assumption rather than a measurement; the 2026-08-04 consolidation run posted the hash and measured the login, so a bcrypt hash is portable through the admin API. Both docs were corrected on 2026-09-02.
Managed-to-managed logical replication does not carry auth.* or storage.* at any tested size (edge-resilience W09, W14): initial sync stalls, received_lsn stays null, while public and custom schemas replicate in about 4 s. A warm standby’s auth posture is third-party auth for existing sessions plus a SQL backfill or forced re-login for fresh ones, and per-project auth config (SMTP, SITE_URL, jwt_exp, rate limits) does not follow a cutover (W17).
Levers, and what each one gates
Section titled “Levers, and what each one gates”| Lever | Gates | Does not gate | Measured |
|---|---|---|---|
disable_signup = true | new signups | an existing user’s login | iap-lockdown L04 |
| disable legacy API keys | the anon and service_role JWTs, in about 45 s | the new sb_publishable_ / sb_secret_ keys; a PAT holder can create a new API key at will (201), so this is a posture, not a lockout | L05, L11 |
| revoke the legacy HS256 signing key | every HS256 token: self-hosted tokens in 3-6 s, and the legacy anon and service_role API keys with them | sb_publishable_ / sb_secret_ | self-hosted-auth SH05 |
Edge Function verify_jwt = true | the no-key caller | any valid project JWT, including the anon key | L07 |
password_min_length | signup and update below the length (422) | security-lockdown S11 | |
| leaked-password protection | signup | password update: a breached password was accepted via PUT /auth/v1/user with HIBP on | S11 |
MFA aal2 | whatever the policy checks auth.jwt()->>'aal' for | anything on aal1; there is no native remember-this-device, and a refreshed session is never re-challenged | mfa guide |
before-user-created hook, CAPTCHA, seven rate_limit_* fields | not measured (present on micro, off by default, settable) | not measured | S14 |
| third-party auth | the Data API’s accepted issuers | Auth, Storage, Realtime, Edge Function endpoints | iap-lockdown |
| network restrictions, PrivateLink | the Postgres and pooler socket | the whole HTTP tier, Auth included | security-lockdown S02, S10 |
| CORS, custom domain | nothing | L13 | |
pgrst.db_pre_request on hosted | nothing observed within 120 s | L09 |
auth.audit_log_entries is empty by default on a fresh hosted project (audit_log_disable_postgres: true, Dashboard-only toggle, Management API PATCH a no-op); the log stream carries every login as an auth_event, and an impersonation minted through generateLink logs the target as actor_id and the admin calls as service_role, never the human. A PAT (sbp_) is a control-plane credential, not an identity token, and RLS never sees it.6
Taking Auth in-house: a self-hosted GoTrue on a managed project
Section titled “Taking Auth in-house: a self-hosted GoTrue on a managed project”Measured 2026-09-02 on three throwaway Micro projects with the public supabase/gotrue:v2.196.0 image, matching the managed version the project’s health endpoint reported, pointed at the project’s Postgres through the session pooler (self-hosted-auth SH01 to SH05).78
Who you connect as. The self-hosting compose file connects Auth as supabase_auth_admin.8 On the platform that role is reserved: ALTER ROLE ... PASSWORD answers 42501: "supabase_auth_admin" is a reserved role, only superusers can modify it, and granting membership answers 42501: ... role memberships are reserved, only superusers can grant them. postgres is not a superuser and not a member. So a self-hosted GoTrue connects as postgres, which has USAGE on auth, INSERT on auth.users, auth.refresh_tokens and auth.sessions, no CREATE on the schema, and no INSERT on auth.schema_migrations. The schema is owned by supabase_admin.
The startup that fails, and why. postgres defaults to search_path = "$user", public, extensions. GoTrue’s migrator looked for schema_migrations in public, created an empty one there, concluded nothing was applied, and died on its first migration: CREATE TABLE IF NOT EXISTS auth.users -> permission denied for schema auth (SQLSTATE 42501), because the privilege check runs before the existence check. supabase_auth_admin carries search_path=auth in its role config, which is why the managed service never meets this. Appending ?search_path=auth to the connection URL made the same start report GoTrue migrations applied successfully count=0: the platform’s 77 migration rows already cover all 70 files the image ships. Drop the stray public.schema_migrations a failed start leaves behind.
What works once it is up, all measured pass:
| Probe | Result |
|---|---|
| admin-create a user through the self-hosted GoTrue | 200; the managed admin list shows the user (one auth.users) |
| self-hosted password grant | HS256, iss https://<ref>.supabase.co/auth/v1 (mirrored by config), aud authenticated, role authenticated, 3600 s, no kid |
| the self-hosted HS256 token against the managed tier | /auth/v1/user 200; PostgREST read of an authenticated-only table 200 with 1 row, anon control 0 rows |
| managed password grant for the same user | 200 (shared hash), ES256 with the in_use kid, same sub |
self-hosted refresh token at the managed /token | 200, new access token ES256 |
managed refresh token at the self-hosted /token | 200, new access token HS256 (re-signed with what it has) |
managed ES256 token at the self-hosted /user, with the HS256 secret alone and no GOTRUE_JWT_KEYS | 403 bad_jwt: the self-hosted side holds only the HS256 secret |
managed ES256 token at the self-hosted /user, with the platform’s ES256 public key supplied as a verify-only JWK in GOTRUE_JWT_KEYS | 200 |
The managed tier trusts the self-hosted tokens because the HS256 key is previously_used, and users and refresh tokens are shared because both services write the same tables. The managed Auth endpoint cannot be turned off (there is no lever), so both GoTrues serve the same users for as long as the project exists.
The two configuration rules. GOTRUE_JWT_KEYS takes a JSON array of JWKs with exactly one sign key;9 supplying the HS256 secret as an oct sign-and-verify JWK plus the managed project’s ES256 public key from its JWKS with key_ops: ["verify"] gives mutual trust. The oct key must carry no kid: with GOTRUE_JWT_KEYS set, GoTrue stamps the signing key’s kid into the header, and the managed PostgREST answered 401 PGRST301 to an HS256 token carrying a kid, both an arbitrary one and the platform’s own HS256 key id, while the managed GoTrue verified the same tokens. Kid-less, both verifiers answered 200. The issuer string is set to the managed one so the tokens are indistinguishable to a policy keyed on iss.
What the platform can take away. PATCH /v1/projects/{ref}/config/auth/signing-keys/{id} {"status": "revoked"} on the HS256 signing key answered 200, and 4 s later on the project the clean SH05 run records (3 s and 6 s on the two earlier projects) the same self-hosted token was refused by the managed /auth/v1/user (403 bad_jwt) and by PostgREST (401 PGRST301). The collateral is the point: the legacy anon API key answered 401 on PostgREST and the legacy service_role API key answered 403 on the admin API, because both are HS256 JWTs under the same secret, while the sb_publishable_ and sb_secret_ API keys kept working. Revoking the HS256 signing key therefore has the same collateral as the disable-legacy-API-keys lever in the table above; whether that lever also revokes the signing key was not measured. The self-hosted GoTrue kept minting; nothing managed accepted the result. A self-hosted signer built on the legacy HS256 secret lives exactly as long as that signing key stays previously_used.
Removing the dependency: an issuer on its own key (SH06, a fourth project). The self-hosted GoTrue was started with a generated ES256 key as its signing key in GOTRUE_JWT_KEYS, plus the managed ES256 public key and the legacy HS256 secret as verify-only keys so the legacy service_role bearer still worked for admin calls to the self-hosted GoTrue. The container was local, so the public half was published from an Edge Function on the project (verify_jwt = false, answering the JWKS on the project’s own hostname) and that URL was registered as third-party auth with the jwks_url shape. Registration answered 201 with resolved_at set on the create response. A self-hosted password grant minted ES256 with the own kid; a read of the authenticated-only table through PostgREST with it answered 200 4 s after the registration (one project, one run; the edge-resilience runs had measured about 30 s cold for a different lab issuer). The managed /auth/v1/user answered 403 bad_jwt to the same token: the managed GoTrue does not honour third-party keys, only the Data API does. The legacy HS256 key was then revoked, and the own-key token still read 200 from PostgREST, both with the legacy anon key in the apikey header and with the sb_publishable_ key. The legacy anon JWT was still accepted in the apikey header when probed 10 s after the revoke, while as a bearer it had failed within 4 s in SH05. One reading is that the API gateway in front of PostgREST matches the apikey header by value and PostgREST verifies only the bearer; a gateway cache that had not yet expired (the legacy-key disable took about 45 s to propagate in L05) is the other, and a later probe was not run.
Not settled. Whether Storage or Realtime verify a third-party token: GET /storage/v1/bucket answered 200 to the own-key token, to a managed token and to the anon key alike, so that probe does not discriminate, and Realtime was not probed. Pooler behaviour under load was not measured. An image ahead of the platform’s migration set (which would try to write auth.schema_migrations as postgres and fail) could not be tested: no public supabase/gotrue tag newer than v2.196.0 existed on 2026-09-02. SMTP, hooks and OAuth providers on the self-hosted side were not run.
Dropping the HTTP tier instead
Section titled “Dropping the HTTP tier instead”The other in-house move keeps managed Auth and removes PostgREST. Wedge the managed Data API (PATCH /v1/projects/{ref}/postgrest {"db_schema": ""} -> 503 PGRST002 within about 4 s; Auth, Storage, Realtime and Edge Functions unaffected) and run postgrest/postgrest against the session pooler as a dedicated NOSUPERUSER role that is a member of anon and authenticated, never postgres and never service_role (security-lockdown S04, S06). Point PGRST_JWKS_URI at the project’s JWKS to keep verifying GoTrue tokens. The db-pre-request IP filter that never fired on hosted (L09) fires on your own PostgREST and refuses a spoofed x-forwarded-for with 403 PT403. Storage and Realtime stay managed and never traverse PostgREST, so this owns the REST layer and nothing else.10
Dropping GoTrue as well means owning signup, OAuth, magic links, token minting, refresh and revocation; the self-hosted section above is the measured middle ground where GoTrue stays GoTrue and only its hosting changes.
Failure signatures
Section titled “Failure signatures”| Signature | Meaning | Measured or reported |
|---|---|---|
401 PGRST303 "JWT issued at future" on the Data API while /auth/v1/user answers 200 | clock skew between the issuer and PostgREST; every session fails at its next refresh, so within one TTL (3600 s) every active session has failed, and nothing recovers until the skew is fixed | public incidents 2026-08-14 and the week before; skew boundary measured W01 |
401 PGRST301 | the key, not the claim: unresolved third-party JWKS, unknown or stale kid, symmetric token where asymmetric expected, HS256 token with a kid | W01, X02, SH04 |
400 refresh_token_already_used then a client sign-out | gotrue-dart below 2.22.0 destroying a valid session; on supabase_flutter 2.15.0 or later (gotrue-dart 2.22.0 or later), an app-side path | auth-refresh-race |
400 refresh_token_not_found at a trusting project | refresh tokens live with the issuer | X03 |
403 bad_jwt from GoTrue | key it does not hold or a revoked key | SH02f, SH05 |
first POST /auth/v1/admin/users on a fresh project answers 500 (unexpected_failure or "Database error checking email") | ACTIVE_HEALTHY is not readiness; the second call about 10 s later succeeds | unexpected_failure on five projects; "Database error checking email" on two |
| Auth 521 for about 75 s, Storage 500 for about 78 s, REST untouched, during a compute restart | the paths do not move together | platform-downtime |
over_email_send_rate_limit on scripted signups | the shared SMTP allows 2 emails per hour; use the admin create plus password grant path | W03, S11 |
What to do about it
Section titled “What to do about it”The practices the measurements support, grouped the way the sections above are. Each names what it rests on.
Keys and verification
- Mint with ES256 through GoTrue or a third-party issuer; if you must mint HS256 from the legacy secret, leave the header without a
kid, because PostgREST refuses any kid on an HS256 token (SH04). - Treat
jwt_secretas a break-glass credential: rotate it after any use, and expect the legacy anon and service_role API keys to die with the HS256 key when it is revoked (SH05, W07). - Alert on the
PGRST303rate for clock skew and onPGRST301for key trust; the raw 401 count mixes both with expired tokens (W01). - Move clients to the
sb_publishable_andsb_secret_keys before disabling or revoking anything legacy; they survived both (L05, SH05).
Sessions and refresh
- Ship supabase_flutter 2.15.0 or later (gotrue-dart 2.22.0 or later); below that a stale refresh destroys a valid session. On or above it, a sign-out is an app-side path: do not call
signOut()on a 401, do not resurrect old tokens from storage, and check the session once on resume (auth-refresh-race). - Single-flight refreshes in your own clients; GoTrue tolerates the direct parent without a time limit but refuses a grandparent past the reuse interval (auth-refresh-race).
- Plan for the issuer being the only place a refresh token is redeemable: if the issuing project is lost, every session ends within one access-token lifetime (X03, tenant-promotion Part 3).
External issuers
- Register with
oidc_issuer_urlorjwks_url, nevercustom_jwks, which never resolves (X01). - Rehearse a cutover: a first-time issuer key can cost about 30 s of
PGRST301before PostgREST trusts it, and the same trust arrives in about 1 s once the key set has been seen (W01, W05, X02). - Do not rotate a shared issuer’s key without a consumer plan: consumers did not re-resolve within 37 minutes, a fresh integration served the stale set, and revocation did not revoke at a consumer. Until that changes, rotate only when every trusting project can be re-registered after the issuer’s JWKS is current, and keep the old key
previously_usedthrough the window (key-rotation R02, R03). - Third-party auth gates the Data API only; the managed Auth endpoint refuses third-party tokens outright (SH06). Design any Storage or Realtime access on top of a Data API decision, since their handling of a third-party token is unmeasured.
Claims and RLS
- Put tenant identity in
app_metadatathrough the admin API at creation and key policies on it;user_metadatais user-writable andsignupdropsapp_metadatasilently (shared-tenancy). - Setting claims over the wire:
set_config('request.jwt.claims', $1, true)inside a transaction, a SECURITY DEFINER wrapper forauth.uid(), and a NOSUPERUSER, NOBYPASSRLS connection role. Never a bareSET, which the transaction pooler leaks to the next client (C01, C02). - Test write policies with
return=minimaland count rows server-side; the PostgREST default reports a landed write as a 403 (tenant-consolidation). - Pair every UPDATE policy with column grants, create views with
security_invoker=true, and fix new-table exposure withALTER DEFAULT PRIVILEGESfor thepostgresgrantor (S13, L08).
Moving identities
- Backfill users through
POST /auth/v1/admin/userswith the bcryptpassword_hash, a suppliedidandapp_metadata; fall back to SQL only when the refresh token itself must travel (C03, P01). - When SQL is the path: five
auth.*tables in FK order, enumerate non-generated columns, let the target assignrefresh_tokens.id,setvalthe sequence, and check for case-variant duplicate emails before the copy (P01, P02, C02). - For PBKDF2 sources, import passwordless and verify locally on first login (pbkdf2 reference).
Levers
verify_jwton an Edge Function is key possession, not authorisation; authorise inside the function or with RLS (L07).- Leaked-password protection covers signup only; enforce it on the password-update path yourself if it matters (S11).
- Disabling legacy keys and revoking the legacy signing key are both reversible only in the sense that the control plane can mint again; treat them as posture and test the new-key path first (L05, SH05).
Running GoTrue yourself on a managed project
- Connect as
postgreswith?search_path=authon the URL through the session pooler; the reservedsupabase_auth_adminis not available to you (SH01). - Sign with your own ES256 key, publish the JWKS from an Edge Function on the project, register it as third-party auth, and keep the platform’s ES256 public key and the legacy HS256 secret as verify-only JWKs; a kid-less
octkey if you must sign HS256. Tokens on your own key survive the legacy revoke (SH04, SH06). - Remember the managed Auth endpoint stays up and serves the same users; there is no lever to turn it off (L04).
Dropping PostgREST instead
- Run your own PostgREST against the session pooler as a dedicated NOSUPERUSER role that is a member of
anonandauthenticated, pointPGRST_JWKS_URIat the project’s JWKS, and keep Storage and Realtime in mind: they stay managed and never route through it (S04, S06, S15).
Where the docs disagree with runtime
Section titled “Where the docs disagree with runtime”| Doc claim | Runtime measured | Severity |
|---|---|---|
| revocation of an asymmetric key is automatic via the discovery endpoint | true at the issuer; a third-party consumer honoured a revoked key for 15 minutes and never re-resolved within 20-37 minutes | docs describe the issuer only |
| leaked-password protection protects passwords | enforced at signup; a breached password was accepted on the update path | docs silent on the path |
| auth audit entries are stored in two places | auth.audit_log_entries is empty by default on hosted; only the log stream carried the events | docs stale for hosted defaults |
password_hash import supports imported hashes | bcrypt, Argon2 and Firebase scrypt only; PBKDF2 answers 500 unexpected_failure, not a 400 | error shape undocumented |
| signup accepts user attributes | app_metadata in the signup body is dropped without error | docs state only the user_metadata half |
self-hosting compose connects Auth as supabase_auth_admin | that role is reserved on a managed project; a self-hosted GoTrue connects as postgres and needs search_path=auth on the URL | not a doc error, a platform difference the compose file cannot know |
| PostgREST and GoTrue verify the same key set | PostgREST rejects an HS256 token carrying any kid; GoTrue accepts it | undocumented |
security_refresh_token_reuse_interval is configurable | PATCH accepted and read back; the running service kept 10 s | propagation or clamp unknown |
Reading the numbers
Section titled “Reading the numbers”- Dates matter here more than in most of this corpus. The platform changed under the measurements at least twice: standby-key creation was time-rate-limited on 2026-08-03/04 (
Please wait until <ISO8601>, 127-144 s) and was not on 2026-08-25 (422 "already has a signing key in standby", no time limit); the hub published a rotated key in about 7 minutes on 2026-08-04 and in 241 s on 2026-08-25. Where two runs disagree, the later figure is stated above. - Third-party acceptance latency has two measured values: about 1.1 s (X02, 2026-08-03, another Supabase project as issuer) and about 30 s cold (W01 and W05, 2026-08-15, a lab ES256 issuer and a Supabase project). Neither run reproduced the other’s conditions.
- All self-hosted GoTrue rows are one image version against one managed version on the same day, as
postgresthrough the session pooler. The kid rule was reproduced on three projects; the revoke timing is 3 s, 6 s and 4 s across them. - Every “not portable” or “cannot” in this doc is stated across a complete probe set or cited to the source it comes from; a negative measured by guessing paths is not in here.
Evidence
Section titled “Evidence”Measured claims map to module ids in the named supabase-lab experiments; every RUNLOG is public under experiments/, and the self-hosted GoTrue runs have redacted artifacts with measurement tables under self-hosted-auth/out/2026-09-02/.
| Claim | Status | How it was checked |
|---|---|---|
ES256 in_use plus HS256 previously_used on a fresh project; jwt_secret readable and mints accepted tokens | measured | edge-resilience W01, W07; self-hosted-auth SH01c |
| PostgREST skew tolerance exactly 30 s; expired and unknown-key codes | measured | edge-resilience W01, two runs |
PostgREST refuses an HS256 token with any kid; GoTrue accepts it | measured | self-hosted-auth SH04, three projects (arbitrary kid, platform kid, no kid) |
| Refresh reuse semantics: parent tolerated, grandparent refused past the interval, family revoked, concurrent dedupe | measured | auth-refresh-race, hosted and local v2.195.0 |
| gotrue-dart 2.21.0 defect and 2.22.0 fix boundary | measured | auth-refresh-race differential harness |
| Refresh token refused at a trusting project | measured | cross-project-auth X03 |
Third-party shapes: two resolve, custom_jwks never; trust on and off in about 1 s | measured | cross-project-auth X01, X02 |
| Consumer cache never re-resolved across 37 and 20 minutes; revoked key honoured 15 minutes in one run | measured | tenant-promotion Part 4; key-rotation R02, R03 |
Wire claims drive RLS with no PostgREST; 6543 leaks a bare SET; GRANT USAGE ON SCHEMA auth no-ops | measured | rls-wire-claims C01, C02, C03 |
app_metadata admin-only (403), user_metadata self-writable, signup drops app_metadata | measured | shared-tenancy, two fresh projects 2026-08-03 |
Policy and grant holes: with check (true), return=representation masking, views, column grants, permissive OR, default ACL rot | measured | tenant-consolidation; iap-lockdown L08; security-lockdown S13 |
| Five-table copy carries the session and the MFA factor; retire at source | measured | tenant-promotion P01, P02, P03 |
Admin API accepts a bcrypt password_hash; PBKDF2 answers 500 | measured | tenant-consolidation C03; pbkdf2 reference reproduction |
| Email case-variant duplicate reachable from both inputs, unstable mapping | measured | tenant-consolidation C02 |
auth.* does not replicate managed-to-managed | measured | edge-resilience W09, W14 |
| Legacy key disable in about 45 s; new keys independent | measured | iap-lockdown L05, L11 |
verify_jwt passes the anon key | measured | iap-lockdown L07 |
| HIBP not enforced on update | measured | security-lockdown S11 |
supabase_auth_admin reserved; postgres privileges on auth; search_path startup failure and fix | measured | self-hosted-auth SH01, RUNLOG |
Shared users and refresh tokens across managed and self-hosted GoTrue; mutual trust with GOTRUE_JWT_KEYS carrying the platform’s ES256 public key | measured | self-hosted-auth SH02, SH03, SH04 |
Revoking the HS256 signing key: 3-6 s to rejection on three projects; legacy API keys die, sb_publishable_ / sb_secret_ survive | measured | self-hosted-auth SH05 |
Self-hosted GoTrue on its own ES256 key, JWKS from an Edge Function, third-party auth jwks_url: PostgREST 200 after 4 s; managed /auth/v1/user 403; token survives the HS256 revoke; the legacy anon JWT, its signing key revoked, still passed as apikey 10 s later (SH06) where it had failed as bearer within 4 s (SH05) | measured | self-hosted-auth SH06 (one project) and SH05 |
| Storage verifies a third-party token | not settled | SH06e: bucket list answers anon too; a discriminating probe was not run |
| Signing-key status semantics | documented | Supabase signing-keys page |
Self-hosting compose connects Auth as supabase_auth_admin | documented | supabase/supabase docker-compose.yml |
GOTRUE_JWT_KEYS is a JSON array of JWKs with one sign key | documented | supabase/auth internal/conf |
| 2026-08-14 JWT rejection incidents | reported | status page, as recorded in the incident-resilience reference |
Related docs
Section titled “Related docs”- Supabase incidents: what a client can do - the incident classes above in full, with the client-side responses.
- RLS without Supabase Auth - the wire-claims pattern this doc summarises.
- Locking down Supabase - every lever, including the ones outside Auth.
- Migrating PBKDF2 password hashes - the import formats and the lazy-migration pattern.
- Where should a tenant live - the isolation proofs and the placement decision.
- Put an IAP over the Data API, Running many tenants on one project, Promoting a tenant, Consolidating tenants, Run your own PostgREST, Trusted-device MFA and impersonation audit - the guides each section points at.
References
Section titled “References”-
Supabase, “JWT Signing Keys,” Supabase Docs. https://supabase.com/docs/guides/auth/signing-keys ↩ ↩2
-
Erfi Anugrah, “Supabase incidents: what a client can actually do,” Erfi’s Lexicanum. https://erfi.dev/reference/supabase-incident-resilience/ ↩ ↩2
-
Erfi Anugrah, “RLS without Supabase Auth: JWT claims over the wire,” Erfi’s Lexicanum. https://erfi.dev/reference/rls-without-supabase-auth/ ↩
-
Erfi Anugrah, “Migrating PBKDF2 password hashes into Supabase Auth,” Erfi’s Lexicanum. https://erfi.dev/reference/pbkdf2-supabase-auth-migration/ ↩
-
Erfi Anugrah, “Locking down Supabase: the database is the security boundary,” Erfi’s Lexicanum. https://erfi.dev/reference/supabase-data-surface-lockdown/ ↩
-
Supabase, “supabase/auth README,” GitHub. https://github.com/supabase/auth/blob/master/README.md ↩
-
Supabase, “docker/docker-compose.yml,” supabase/supabase on GitHub. https://github.com/supabase/supabase/blob/master/docker/docker-compose.yml ↩ ↩2
-
Supabase, “internal/conf/configuration.go and jwk.go,” supabase/auth on GitHub. https://github.com/supabase/auth/tree/master/internal/conf ↩
-
Erfi Anugrah, “Run your own PostgREST against a Supabase project,” Erfi’s Lexicanum. https://erfi.dev/guides/supabase-own-postgrest/ ↩