Skip to content

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_use and the legacy HS256 secret previously_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 iat more than 30 s in the future (401 PGRST303), an unknown key (401 PGRST301), and an HS256 token that carries any kid at 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_found to 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() reads request.jwt.claims; set it over the wire and per-user RLS works with no PostgREST and no GoTrue. The transaction pooler leaks a bare SET across clients; use set_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 postgres role with search_path=auth, shares users and refresh tokens with the managed one, and is trusted by PostgREST as long as the legacy HS256 signing key stays previously_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.

managed projectclientGoTrue (managed)signs ES256, in_usepassword / refreshPostgRESTverifies, sets GUCbearer tokenPostgresauth.* schemaRLS reads request.jwt.claimsraw wire + set_config(no PostgREST)GoTrue (self-hosted)postgres role, search_path=authsigns HS256 (legacy secret)password / refreshexternal issuer(OIDC / JWKS)third-party auth:cached JWKSauth.users, sessions,refresh_tokenssigning keysES256 in_useHS256 previously_usedStorage / Realtime /Edge Functionsverify separatelyset role +request.jwt.claimssame tables,via session pooler
GoalShapeWhere it is measured
Ordinary app, one projectManaged GoTrue, verify at the edge with cached JWKS (~0 ms) or round-trip getUser() (~207 ms) when revocation mattersCloudflare Workers + Supabase
Many tenants, one projectManaged GoTrue, tenant_id in app_metadata set by the admin API, RLS keyed on itRunning many tenants on one project
Corporate identity in front of the Data APIThird-party auth with the IdP’s issuer, RLS keyed on iss, signups disabledPut an IAP over the Data API
Keep Supabase Auth, drop PostgRESTVerify the GoTrue JWT in your backend, set request.jwt.claims over the wireRLS without Supabase Auth
Move a tenant between projects with no re-loginCopy the five auth.* tables in FK order, resync the sequence, retire the source identityPromoting a tenant
Bring users from a PBKDF2 systemPasswordless import plus a local verifier in an Edge FunctionMigrating PBKDF2 hashes
Run the auth server yourself on a managed projectSelf-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 JWKsthis doc, Taking Auth in-house

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 checksPostgRESTManaged GoTrueMeasured
iat skew+30 s accepted, +31 s 401 PGRST303not probedW01, two runs
expired token401 PGRST303403 bad_jwtW01; self-hosted-auth SH05
unknown key401 PGRST301403 bad_jwtW01; SH02f
HS256 token carrying a kid401 PGRST301, for an arbitrary kid and for the platform’s own HS256 key id200SH04, three projects
HS256 token with no kid200200SH02d, SH04c
jwt_exp changen/a - exp is minted by the issuerhonours it about 6.5 s after the PATCH reads backW03

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

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_used once 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_interval to 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.

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.

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 auth to a custom role silently no-ops and auth.uid() errors permission denied for schema auth. The working shape is a SECURITY DEFINER wrapper owned by postgres that the policy calls.
  • The session pooler (5432) resets a bare SET on return. The transaction pooler (6543) leaks it to the next client. Claims belong in set_config('request.jwt.claims', $1, true) inside a transaction, never a bare SET, and never interpolated into SQL text.
  • A tampered sub is enforced exactly as written: the probe’s made-up sub matched 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:

ShapeResultMeasured
for all ... using (...) with no with checkPostgres reuses using as the check; cross-tenant insert and reassignment both refusedshared-tenancy Part 3, Postgres 17.10
with check (true)tenant B’s row attributed to tenant A landed while every read test passedtenant-consolidation
PostgREST default return=representationa 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 201tenant-consolidation
RLS enabled, no policy200 and [] - a blank app, not an errortenant-consolidation
plain VIEW over an RLS tableleaks all rows to anon; security_invoker=true returns 0iap-lockdown L08
UPDATE policygates 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 42501security-lockdown S13
two PERMISSIVE policiesOR together, so adding a second permissive policy widened what anon could readiap-lockdown L08
REVOKE SELECT ... FROM anonundone 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.

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_at and auth.identities.email are GENERATED ALWAYS; insert ... select * fails 428C9. Enumerate columns with is_generated = 'NEVER'; there were 34 on auth.users.
  • auth.refresh_tokens.user_id is character varying, not uuid. A uuid predicate 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 with refresh_tokens_pkey on any target with prior auth activity, on the tenant’s next refresh rather than during the move.
  • users_email_partial_key is UNIQUE (email) WHERE (is_sso_user = false) over the raw column. The admin API normalises case and refuses a variant with 422 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 with invalid_credentials and the old refresh token with refresh_token_not_found while 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).

LeverGatesDoes not gateMeasured
disable_signup = truenew signupsan existing user’s loginiap-lockdown L04
disable legacy API keysthe anon and service_role JWTs, in about 45 sthe 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 lockoutL05, L11
revoke the legacy HS256 signing keyevery HS256 token: self-hosted tokens in 3-6 s, and the legacy anon and service_role API keys with themsb_publishable_ / sb_secret_self-hosted-auth SH05
Edge Function verify_jwt = truethe no-key callerany valid project JWT, including the anon keyL07
password_min_lengthsignup and update below the length (422)security-lockdown S11
leaked-password protectionsignuppassword update: a breached password was accepted via PUT /auth/v1/user with HIBP onS11
MFA aal2whatever the policy checks auth.jwt()->>'aal' foranything on aal1; there is no native remember-this-device, and a refreshed session is never re-challengedmfa guide
before-user-created hook, CAPTCHA, seven rate_limit_* fieldsnot measured (present on micro, off by default, settable)not measuredS14
third-party auththe Data API’s accepted issuersAuth, Storage, Realtime, Edge Function endpointsiap-lockdown
network restrictions, PrivateLinkthe Postgres and pooler socketthe whole HTTP tier, Auth includedsecurity-lockdown S02, S10
CORS, custom domainnothingL13
pgrst.db_pre_request on hostednothing observed within 120 sL09

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:

ProbeResult
admin-create a user through the self-hosted GoTrue200; the managed admin list shows the user (one auth.users)
self-hosted password grantHS256, 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 user200 (shared hash), ES256 with the in_use kid, same sub
self-hosted refresh token at the managed /token200, new access token ES256
managed refresh token at the self-hosted /token200, 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_KEYS403 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_KEYS200

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.

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.

SignatureMeaningMeasured or reported
401 PGRST303 "JWT issued at future" on the Data API while /auth/v1/user answers 200clock 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 fixedpublic incidents 2026-08-14 and the week before; skew boundary measured W01
401 PGRST301the key, not the claim: unresolved third-party JWKS, unknown or stale kid, symmetric token where asymmetric expected, HS256 token with a kidW01, X02, SH04
400 refresh_token_already_used then a client sign-outgotrue-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 pathauth-refresh-race
400 refresh_token_not_found at a trusting projectrefresh tokens live with the issuerX03
403 bad_jwt from GoTruekey it does not hold or a revoked keySH02f, 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 succeedsunexpected_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 restartthe paths do not move togetherplatform-downtime
over_email_send_rate_limit on scripted signupsthe shared SMTP allows 2 emails per hour; use the admin create plus password grant pathW03, S11

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_secret as 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 PGRST303 rate for clock skew and on PGRST301 for key trust; the raw 401 count mixes both with expired tokens (W01).
  • Move clients to the sb_publishable_ and sb_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_url or jwks_url, never custom_jwks, which never resolves (X01).
  • Rehearse a cutover: a first-time issuer key can cost about 30 s of PGRST301 before 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_used through 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_metadata through the admin API at creation and key policies on it; user_metadata is user-writable and signup drops app_metadata silently (shared-tenancy).
  • Setting claims over the wire: set_config('request.jwt.claims', $1, true) inside a transaction, a SECURITY DEFINER wrapper for auth.uid(), and a NOSUPERUSER, NOBYPASSRLS connection role. Never a bare SET, which the transaction pooler leaks to the next client (C01, C02).
  • Test write policies with return=minimal and 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 with ALTER DEFAULT PRIVILEGES for the postgres grantor (S13, L08).

Moving identities

  • Backfill users through POST /auth/v1/admin/users with the bcrypt password_hash, a supplied id and app_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 assign refresh_tokens.id, setval the 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_jwt on 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 postgres with ?search_path=auth on the URL through the session pooler; the reserved supabase_auth_admin is 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 oct key 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 anon and authenticated, point PGRST_JWKS_URI at the project’s JWKS, and keep Storage and Realtime in mind: they stay managed and never route through it (S04, S06, S15).
Doc claimRuntime measuredSeverity
revocation of an asymmetric key is automatic via the discovery endpointtrue at the issuer; a third-party consumer honoured a revoked key for 15 minutes and never re-resolved within 20-37 minutesdocs describe the issuer only
leaked-password protection protects passwordsenforced at signup; a breached password was accepted on the update pathdocs silent on the path
auth audit entries are stored in two placesauth.audit_log_entries is empty by default on hosted; only the log stream carried the eventsdocs stale for hosted defaults
password_hash import supports imported hashesbcrypt, Argon2 and Firebase scrypt only; PBKDF2 answers 500 unexpected_failure, not a 400error shape undocumented
signup accepts user attributesapp_metadata in the signup body is dropped without errordocs state only the user_metadata half
self-hosting compose connects Auth as supabase_auth_adminthat role is reserved on a managed project; a self-hosted GoTrue connects as postgres and needs search_path=auth on the URLnot a doc error, a platform difference the compose file cannot know
PostgREST and GoTrue verify the same key setPostgREST rejects an HS256 token carrying any kid; GoTrue accepts itundocumented
security_refresh_token_reuse_interval is configurablePATCH accepted and read back; the running service kept 10 spropagation or clamp unknown
  • 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 postgres through 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.

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/.

ClaimStatusHow it was checked
ES256 in_use plus HS256 previously_used on a fresh project; jwt_secret readable and mints accepted tokensmeasurededge-resilience W01, W07; self-hosted-auth SH01c
PostgREST skew tolerance exactly 30 s; expired and unknown-key codesmeasurededge-resilience W01, two runs
PostgREST refuses an HS256 token with any kid; GoTrue accepts itmeasuredself-hosted-auth SH04, three projects (arbitrary kid, platform kid, no kid)
Refresh reuse semantics: parent tolerated, grandparent refused past the interval, family revoked, concurrent dedupemeasuredauth-refresh-race, hosted and local v2.195.0
gotrue-dart 2.21.0 defect and 2.22.0 fix boundarymeasuredauth-refresh-race differential harness
Refresh token refused at a trusting projectmeasuredcross-project-auth X03
Third-party shapes: two resolve, custom_jwks never; trust on and off in about 1 smeasuredcross-project-auth X01, X02
Consumer cache never re-resolved across 37 and 20 minutes; revoked key honoured 15 minutes in one runmeasuredtenant-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-opsmeasuredrls-wire-claims C01, C02, C03
app_metadata admin-only (403), user_metadata self-writable, signup drops app_metadatameasuredshared-tenancy, two fresh projects 2026-08-03
Policy and grant holes: with check (true), return=representation masking, views, column grants, permissive OR, default ACL rotmeasuredtenant-consolidation; iap-lockdown L08; security-lockdown S13
Five-table copy carries the session and the MFA factor; retire at sourcemeasuredtenant-promotion P01, P02, P03
Admin API accepts a bcrypt password_hash; PBKDF2 answers 500measuredtenant-consolidation C03; pbkdf2 reference reproduction
Email case-variant duplicate reachable from both inputs, unstable mappingmeasuredtenant-consolidation C02
auth.* does not replicate managed-to-managedmeasurededge-resilience W09, W14
Legacy key disable in about 45 s; new keys independentmeasurediap-lockdown L05, L11
verify_jwt passes the anon keymeasurediap-lockdown L07
HIBP not enforced on updatemeasuredsecurity-lockdown S11
supabase_auth_admin reserved; postgres privileges on auth; search_path startup failure and fixmeasuredself-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 keymeasuredself-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_ survivemeasuredself-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)measuredself-hosted-auth SH06 (one project) and SH05
Storage verifies a third-party tokennot settledSH06e: bucket list answers anon too; a discriminating probe was not run
Signing-key status semanticsdocumentedSupabase signing-keys page
Self-hosting compose connects Auth as supabase_auth_admindocumentedsupabase/supabase docker-compose.yml
GOTRUE_JWT_KEYS is a JSON array of JWKs with one sign keydocumentedsupabase/auth internal/conf
2026-08-14 JWT rejection incidentsreportedstatus page, as recorded in the incident-resilience reference
  1. Supabase, “JWT Signing Keys,” Supabase Docs. https://supabase.com/docs/guides/auth/signing-keys 2

  2. Erfi Anugrah, “Supabase incidents: what a client can actually do,” Erfi’s Lexicanum. https://erfi.dev/reference/supabase-incident-resilience/ 2

  3. Erfi Anugrah, “RLS without Supabase Auth: JWT claims over the wire,” Erfi’s Lexicanum. https://erfi.dev/reference/rls-without-supabase-auth/

  4. Erfi Anugrah, “Running many tenants on one Supabase project,” Erfi’s Lexicanum. https://erfi.dev/guides/supabase-shared-tenancy/

  5. Erfi Anugrah, “Migrating PBKDF2 password hashes into Supabase Auth,” Erfi’s Lexicanum. https://erfi.dev/reference/pbkdf2-supabase-auth-migration/

  6. Erfi Anugrah, “Locking down Supabase: the database is the security boundary,” Erfi’s Lexicanum. https://erfi.dev/reference/supabase-data-surface-lockdown/

  7. Supabase, “supabase/auth README,” GitHub. https://github.com/supabase/auth/blob/master/README.md

  8. Supabase, “docker/docker-compose.yml,” supabase/supabase on GitHub. https://github.com/supabase/supabase/blob/master/docker/docker-compose.yml 2

  9. Supabase, “internal/conf/configuration.go and jwk.go,” supabase/auth on GitHub. https://github.com/supabase/auth/tree/master/internal/conf

  10. Erfi Anugrah, “Run your own PostgREST against a Supabase project,” Erfi’s Lexicanum. https://erfi.dev/guides/supabase-own-postgrest/