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.
Every row marked measured was run against a real project by the supabase-lab harness between 2026-07-09 and 2026-09-02, in the Auth rate-limit battery on 2026-09-08, and in the session-carry runs at 02:55 and 02:56 UTC on 2026-09-25 (one project pair, plus a third project for S01x). The runs of 2026-10-10 are ER01 (Edge Function verification with @supabase/server 1.9.1, Pro organisation), K06 (an app MCP server block, Team organisation), FE01 (auth email templates on three new projects in Free organisations) with FE02 as its Pro control, and DD01 (default privileges on API-created Pro projects, two runs with one project each, Postgres 17.11, figures from run 2), all in ap-southeast-1, with n = 1 per cell unless a row says otherwise. Each project was created and destroyed for the experiment; 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. - An Edge Function’s
verify_jwtis a gateway check on the bearer, and it does not rejectsb_keys. Withverify_jwttrue a bare publishable key, a bare secret key and the publishable key in both headers all reached the handler (200); the gateway refused a missing credential and an expired, third-party or foreign bearer. It passed a legacy HS256 token that the@supabase/serverlibrary’susermode refuses (INVALID_JWT), and refused a third-party token that PostgREST accepted (ER01, 2026-10-10). - 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.
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. - A session moves between projects only by SQL. Five
auth.*tables copied in FK order move a tenant with its existing refresh token and its MFA factor; an aal2 session stays aal2 at the new project only ifauth.mfa_amr_claimsis copied as well. The admin API can mint a user with a bcrypt hash but cannot carry a session. - A sign-up burst is capped by CPU and by the Auth connection pool before it reaches any rate limit. Against the documented 30-request bucket, a fresh bucket on the anonymous sign-in path answers
429at request 31, and the built-in email provider refuses the 3rd send inside the hour; but admin-create latency itself rose from a p50 of 102/105/110ms sequential to 411/368/366ms at concurrency 8, and GoTrue’s pool is 10 sessions against the project’smax_connectionsof 60. - 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, plus auth.mfa_amr_claims after auth.sessions so an aal2 session stays aal2, resync the sequence, retire the source identity | Promoting a tenant; session-carry S04 for auth.mfa_amr_claims |
| Bring users from a PBKDF2 system | Passwordless import plus a local verifier in an Edge Function | Migrating PBKDF2 hashes |
| Verify the caller inside an Edge Function or Worker | @supabase/server user mode for project-issued ES256 tokens; a separate verifier for third-party and legacy HS256 tokens | this doc, Verifying inside the function; Auth modes for @supabase/server |
| 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, recorded in its RUNLOG rather than by a module - R01 measures the standby-key rate limit).
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 any PAT holder can read it; 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 |
ES256 token from another project, whose private key material the verifying project holds under a different kid | 401 PGRST301; kid lookup inferred, not shown | 403 bad_jwt “unrecognized JWT kid … for algorithm ES256” | session-carry S01f, S01g, one run |
jwt_exp change | n/a - exp is minted by the issuer | honours it about 6.5 s after the PATCH reads back (W03). Re-seen 2026-10-10 in ER01: after the Management API read back jwt_exp 300 the first sign-in still got a 3600 s token and the second, 10 s later, got 300 s; restoring 3600 lagged by the same 10 s. Poll sign-ins for the lifetime, not the readback | W03; ER01, one project, 10 s retry step |
For an HS256 token, PostgREST looks the kid up in its key set and the legacy HS256 secret is not addressable by one, while the managed GoTrue accepted the token whatever its kid (SH04). Any signer that builds HS256 tokens from the legacy secret must leave the header without a kid.
For an ES256 token the managed GoTrue selects the key by kid. In session-carry the target imported the source’s private key material under a fresh kid (S01c, import 201). Its JWKS was publishing that kid before the probes ran: S01e saw it 450882 ms after polling began, and polling began after S01d, not at the S01c promotion. Target Auth /user then refused a source access token signed with the imported kid 403 bad_jwt “unrecognized JWT kid … for algorithm ES256” (S01g), so identical material under a different kid does not verify. Target PostgREST refused the same token 401 PGRST301 (S01f); that PostgREST looks an ES256 token up by kid is inferred, since PGRST301 is equally consistent with a verifier cache that had not picked the new key up yet (RUNLOG).
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
Verifying inside the function
Section titled “Verifying inside the function”The @supabase/server library verifies a second time, in the handler, under an auth mode: none, user, secret, publishable, or a list such as ['user','secret']. ER01 (2026-10-10, @supabase/server 1.9.1, Pro, ap-southeast-1, one project, one run) sent 14 credential presentations to each of 5 modes, 70 cells per target, on an Edge Function with verify_jwt false and true. Against the package docs, 0 of the 44 cells the docs state differed on the verify_jwt false function, and 26 cells are ones the docs do not state.3
usermode accepts only a project-issued ES256 token. An expired token, a third-party-issuer token (registered through third-party auth), a token signed by a key no project trusts, a legacy HS256 token under the shared secret, and the legacy anon and service_role JWTs were each refused401 INVALID_JWT.- A bad JWT in
['user','secret']is refused, not downgraded to the secret path. The expired, third-party, foreign and HS256 tokens, each sent with the publishable key inapikey, were refusedINVALID_JWT; a request carrying only the secret key still matchedsecret(200). - A valid user JWT with no
apikey, sent tosecretorpublishablemode, returns401 INVALID_CREDENTIALS. The package docs describe that code as a fallback, with the specific codes covering essentially every real failure. - The expiry boundary, on a token issued with
jwt_exp300 s, probed about every 4.2 s: accepted 2.9 s beforeexp, refusedINVALID_JWT1.4 s after it (12 probes from +1.4 s onward refused). The probes are 4.3 s apart acrossexpand the offsets are on the probing machine’s clock, so a leeway under about 2 s is neither shown nor excluded. - Runtime variables on the Edge Function:
SUPABASE_PUBLISHABLE_KEYSandSUPABASE_SECRET_KEYS(each carrying one key nameddefault) andSUPABASE_JWKS(one ES256 key) were set. The singularSUPABASE_PUBLISHABLE_KEYandSUPABASE_SECRET_KEY, andSUPABASE_JWKS_URL, were absent.
The gateway and the library disagree on a legacy HS256 token and on a third-party token (see the note above), so a function behind verify_jwt true has two verifiers with different rules. The full 70-cell matrix is in Auth modes for @supabase/server.
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.
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 and 30 s figures come from different runs and issuers (see Reading the numbers); rehearse a cutover rather than assume either.
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).4
Measured rules:
- 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.5
| 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). Measured 2026-10-10 on a project created through POST /v1/projects: pg_default_acl for postgres in public still granted arwdDxtm on tables to anon, authenticated and service_role, and a table made in SQL read 200 for anon with no GRANT; the public notice documents a default that exposes nothing on new projects from 2026-05-30,6 so that default was not seen on an API-created project, and the creation path was not separated from a gradual rollout. After the notice’s ALTER DEFAULT PRIVILEGES ... REVOKE a new table answered anon and the publishable key 401, and service_role and the secret API key 403, each 42501, and a GRANT SELECT to anon reopened it at once. The re-run after the 2026-10-30 enforcement date has not been done | iap-lockdown L08; data-api-defaults DD01, one project, 2026-10-10 |
The Management API query endpoint connects as postgres and sees every row; verifying that data landed says nothing about isolation.
OAuth client tokens and RLS
Section titled “OAuth client tokens and RLS”K06 (2026-10-10, Team organisation, ap-southeast-1, one new project) deployed the Select 2026 “MCP server for your app” block, built on Supabase Middleware 1.0,78 as an Edge Function with --use-api --no-verify-jwt, so the function does its own verification. The project signed with ES256 (in_use) and listed an HS256 key previously_used. The unauthenticated call answered 401 with WWW-Authenticate: Bearer and a resource_metadata URL; dynamic client registration answered 201 for each of two public clients. The consent step was the API call a consent page makes, not a browser click.
- 4 of 4 headless code flows (PKCE; three users on client A, one on client B) issued a token carrying
client_idequal to the registered client,aud=authenticated,role=authenticated,scope=email, headeralgES256 and a 3600 s lifetime. - RLS keyed on
auth.jwt() ->> 'client_id'isolated the callers. With rows tagged for client A, client B and no client, one user’s client A token read only the client A row, the same user’s client B token only the client B row, and a second user’s client A token the client A row. A password-session token (no OAuth,client_idnull, also ES256) was accepted by the function and read only the untagged row, so a policy has to name both paths. - The project’s publishable key, the legacy anon JWT, a non-JWT string and a one-character string were each refused
401by the function. - The scope identifies and does not limit. The same
scope=emailtoken sent to the Data API (/rest/v1/purchase_requests) answered200with 2 rows.
Not done: an interactive client login, the OAuth Consent block, a Workers deploy, and a user token signed with the legacy HS256 secret, which the block’s docs say it rejects.
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.
Session-carry ran on 2026-09-25 in ap-southeast-1: S02 and S04 on two Micro projects in the 02:55 UTC run, S01 at 02:56 UTC on the same pair plus, for S01x, a free-plan project in a second organization under the same account.
The copy moves the factor but not the record of how the session authenticated: an aal2 session whose first refresh ran at the target came back aal1 with no amr claim (S04a), while the same copy plus auth.mfa_amr_claims, inserted after auth.sessions because it references it, kept aal2 with amr password+totp (S04b); the control, S04a’s refresh token refreshed at the source, stayed aal2 (S04c).
The access token the client holds at cutover does not work at the target: with the rows copied and each project on its own ES256 signing key, target Auth /user refused it 403 bad_jwt and target PostgREST refused it 401 PGRST301 (S02a, S02b), while the held refresh token refreshed at the target with 200 (S02c) and the refreshed access token was accepted at target Auth /user with 200 (S02e).
A shared signing key does not close that gap on the hosted platform: a kid another project holds is refused 409 (S01b, S01x), and the same private material under a new kid is refused by both verifiers (S01f, S01g; see The token: keys and verifiers).
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.9 The 2026-08-15 standby run (edge-resilience W09) posted its backfill users without a password_hash and recorded the limitation as an assumption; the 2026-08-04 consolidation run posted the hash and measured the login, so a bcrypt hash is portable through the admin API. The incident-resilience reference (class 9) and the resilience runbook, which had followed W09, 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).
When a provider subject changes
Section titled “When a provider subject changes”An identity is the pair (provider, subject), and the lookup that decides “known
user” is on that pair: provider_id and identity_data.sub carry the same
value, and a new subject is a stranger until something else says otherwise
(IT01a). A provider that reissues subjects for the same person - a vendor
migration, a team transfer, a re-created app registration - therefore arrives
as a new user unless one of three things happens, and which one it is decides
whether the person keeps their auth.users.id.
| What arrives | What Auth does |
|---|---|
| new subject, verified email matching exactly one non-SSO user | links to the existing user: same user id in the token, a second row in auth.identities (IT01b) |
| new subject, a different email | a new user, none of the old rows (IT01c) |
new subject, email matching but email_verified = false | no session: error=access_denied, error_code=provider_email_needs_verification; the old user keeps 1 identity row (IT01e) |
The first row is the linked-by-email rule the docs describe as automatic on the same address.10 The other two are what a migration has to handle. Both come back to the same row, because the match is read from it, and a rewrite there is what a migration controls:
update auth.identities set provider_id = '<new subject>' where provider_id = '<old subject>' and user_id = '<user>';The column is what the lookup reads. A later run rewrote only provider_id
and left identity_data.sub at the old subject; the new subject, signing in
with a different email, still landed on the old user with one identity row
(ITL1b), so updating the JSON copy as well is tidiness rather than the
mechanism. auth.users.id is untouched, so RLS ownership and every foreign
key from public survive the rewrite.
Everything else repairs itself on the next sign-in except the primary email.
raw_user_meta_data.sub, .provider_id and .email, and identity_data.sub
and .email, were all the new values after that sign-in from the old ones
immediately before it, while auth.users.email and the email claim in the
issued token stayed at the old address (ITL1c); the 2026-09-07 managed run
saw the same split (IT01d). A migration that stops at the identity row leaves
the project mailing a dead relay address until auth.users.email is updated
too.
Two other levers are narrower than they look:
- A custom claim does not steer the lookup.
DetermineAccountLinkingopens on the identity pair, so a claim naming the old subject is stored and never read. Atransfer_sub-shaped claim was written to both the identity and the user (identity_data.custom_claimsandraw_user_meta_data.custom_claims, ITL2a), and a sign-in whose claim held the old identity’s exact subject, with an email matching nothing, produced a new user (ITL2b). The copies are not durable either: one sign-in without the claim and both were gone, becauseidentity_datais replaced wholesale and the top-levelcustom_claimsmap is overwritten (ITL2c). Harvest transfer identifiers from the provider’s own endpoint during its window; do not plan to read them back out of the database later. - The Before User Created hook fires on the create decision only. A
sign-in carrying a claim the hook refuses got a session down the link path
(ITL3b) and down the existing-user path (ITL3c), while a stranger carrying
the same claim was refused with
error=access_denied, noerror_code, and the hook’s own digest of its payload in the message -user_metadatakeys includingcustom_claims, withtransfer_submatching what the issuer sent (ITL3d; the same shape with a claim the hook accepts was created, ITL3e). Noauth.usersrow was left for the refused address. A hook can therefore alarm on exactly the post-migration users a remap missed, but it will not catch the remap of a user who already exists.
One trace on the refusal path matters before a cleanup: the
unverified-email refusal still wrote 1 identity row for the new subject and 1
user row for it, with auth.users.email NULL and unconfirmed (IT01e), so a
sweep keyed on auth.users.email does not see it.
IT01 ran on 2026-09-07 against a managed micro project in ap-southeast-1.
ITL1-ITL3 ran on 2026-09-11 against a self-hosted supabase/auth:v2.197.0
with a throwaway Postgres on a local rig, chosen because the four files the
cases turn on (internal/models/linking.go, internal/api/hooks.go,
internal/api/provider/keycloak.go, internal/api/provider/oidc.go) were
byte-identical between that tag and master as fetched that day. GoTrue
imports what the provider reports and the account-resolution code runs after
every provider, Apple included, so a lab provider slot stands in for the
vendor path.
What is not measured: Apple was never minted, so nothing vendor-specific is
settled - neither Apple’s own user-migration exchange nor the audience rule
across a Services ID and a bundle ID. The managed platform’s hook
configuration (hook_before_user_created_* is a field group in the
Management API document) was not probed: ITL2 and ITL3 answered those
questions on the local rig, and the managed-vantage modules for them have
never run.
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 disabling them sets a posture and locks no PAT holder out | 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 (Missing authorization header); an expired, third-party or foreign bearer (Invalid JWT) | any valid project JWT, including the anon key; a bare sb_publishable_ or sb_secret_ key in apikey (200 in both) | L07; ER01, 2026-10-10 |
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.11
Email templates on Free
Section titled “Email templates on Free”New Free projects on the default email provider cannot modify their auth email templates from 2026-06-03; the announcement says existing Free projects keep theirs, paid plans are unaffected, and a Free project that configures its own SMTP provider can customise templates.12 FE01 measured that wording through PATCH /v1/projects/{ref}/config/auth on 2026-10-10 against three new projects in Free organisations (ap-southeast-1), with FE02 as a Pro-organisation control (n = 1).
| State | Template writes | Measured |
|---|---|---|
| Free, default SMTP | 6 of 6 mailer_templates_*_content fields refused when sent alone, 3 of 3 runs; all six mailer_subjects_* in one PATCH refused, 3 of 3 runs; the seven notification templates in one PATCH refused, 2 runs. Each refusal is 400 Email template modification is not available for free tier projects using the default email provider, and 0 of 6 content fields persisted. A site_url PATCH and enabling a notification flag returned 200 | FE01 |
| Free, dummy custom SMTP fields written (host, port, user, password, admin email, sender name) | 6 of 6 content PATCHes 200 and persisted, subjects 200, 3 of 3 runs; the SMTP write was accepted for a host that is not an SMTP server | FE01 |
Free, smtp_host alone | refused 400, 2 runs; which fields lift the refusal was not isolated. One PATCH carrying the six SMTP fields plus a template field returned 200, 3 of 3 runs | FE01 |
| Free, SMTP fields cleared (null) | the clear returned 200; the stored custom templates were gone (0 of 6 still stored, 3 of 3 runs; 6 of 6 equal to the default text in the 2 runs that read it), and template writes were refused again, 3 of 3 runs | FE01 |
| Pro, default SMTP | 6 of 6 content PATCHes, subjects and notification templates all 200 and persisted | FE02 |
The Pro control is consistent with a plan-dependent check and does not prove one, since the Free and Pro organisations may differ in more than plan. The artifacts record no organisation identifier, so which organisation held each project is an operator note, not a recorded measurement. Not measured: Free projects created before 2026-06-03, email delivery (no auth email was triggered, so whether a stored template renders, or what a send to the dummy host does, was not observed), the Dashboard path, the timing of the template reset after the clear, and plans other than Free and Pro.
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).1314
Which role to connect as
Section titled “Which role to connect as”The self-hosting compose file connects Auth as supabase_auth_admin.14 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 search_path startup failure
Section titled “The search_path startup failure”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
Section titled “What works once it is up”Every probe below was measured and passed.
| 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.
Configuration rules
Section titled “Configuration rules”GOTRUE_JWT_KEYS takes a JSON array of JWKs with exactly one sign key;15 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.
Revoking the HS256 signing key
Section titled “Revoking the HS256 signing key”PATCH /v1/projects/{ref}/config/auth/signing-keys/{id} {"status": "revoked"} on the HS256 signing key answered 200, and then the same self-hosted token was refused by the managed /auth/v1/user (403 bad_jwt) and by PostgREST (401 PGRST301): 4 s later on the project of the clean SH05 run, 3 s and 6 s later on the two earlier projects. The revoke also took out the legacy API keys: 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.
Signing with its own key (SH06)
Section titled “Signing with its own key (SH06)”SH06 ran on 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 its 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. Either the API gateway in front of PostgREST matches the apikey header by value while PostgREST verifies only the bearer, or a gateway cache had not expired yet (the legacy-key disable took about 45 s to propagate in L05); no later probe was run to tell them apart.
Open questions
Section titled “Open questions”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 show whether Storage verifies a third-party token, 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.
Rate limits and sign-up throughput
Section titled “Rate limits and sign-up throughput”Auth rate-limits per IP with a token bucket whose capacity is 30 for most endpoints, and per project for email and SMS.16 The numbers below put the boundary where it lands rather than where the table says it is, and separate what caps a sign-up burst from what only looks like it does.
Measured 2026-09-08 on one throwaway project in each of a Free, a Pro and a Team organization, all in ap-southeast-1 (AR01-AR04, 8 pass / 0 fail per tier). Every row below was identical across the three plans, which is why the plan is not a column.
| Boundary | Documented | Measured 2026-09-08 | Module |
|---|---|---|---|
| Anonymous sign-in, per IP, first cut | bucket capacity equal to the configured limit | 60 x 422 when the burst fired immediately after anonymous sign-ins were switched on. Polling /auth/v1/settings for external.anonymous_users first, the burst trips 429 as documented | AR01a |
| Anonymous sign-in, per IP, counted | capacity 30 | 429 at request 31 of 60 on a fresh bucket | AR02a |
| Email sends, built-in provider, per project | 2 per hour | the 3rd signup inside the hour refused 429 over_email_send_rate_limit "email rate limit exceeded" | AR01b |
| Config defaults behind those buckets | not stated as absolute values | anonymous 30, email 2, otp 30, verify 30, token_refresh 150, sms 30 | AR01c |
The switch case is worth keeping: a 422 from the signup path is not a rate limit and not a bad request, it is a setting that has not propagated yet. Poll the settings endpoint for the feature you enabled before reading a burst as a failure.
Sb-Forwarded-For moves the bucket
Section titled “Sb-Forwarded-For moves the bucket”With IP forwarding off the header is ignored: anonymous sign-ins carrying 60 distinct Sb-Forwarded-For values drew their 429 from the caller’s own bucket at request 31 (AR02a). With security_sb_forwarded_for_enabled on and a secret key, the same 60 requests over distinct forwarded addresses drew no 429 at all (AR02b). The differential is the claim; neither half alone shows it.
The docs add a credential condition that the run satisfied rather than tested: forwarding needs a secret API key, and publishable or legacy anon and service_role keys are not accepted for it.16 A server-side client that forwards an end-user IP while holding the wrong key is not rebucketed, and keeps spending the server’s own bucket.
What actually caps a burst
Section titled “What actually caps a burst”Sign-up throughput on a small instance is CPU-bound and then pool-bound, well before the IP bucket is in play. Admin-create hashes the password and sends no email (AR03, measured, p50 in ms, Pro/Team/Free):
| Path | Sequential | Concurrency 8 |
|---|---|---|
| Pro | 102 | 411 |
| Team | 105 | 368 |
| Free | 110 | 366 |
GoTrue’s share of the database connection pool is 10 sessions (db_max_pool_size=10) against max_connections=60 on all three plans (AR04, read-only), so 10 concurrent hashing sign-ups is the other ceiling beside core count. Rate limits bound abuse; they do not bound a launch, and an application-level throttle of your own will fire after the pool already has.
What this did not settle
Section titled “What this did not settle”- The sign-in/sign-up and token-endpoint buckets were counted only on the anonymous sign-in path (AR02a). The bucket shape is shared; the boundary number is one endpoint’s.
rate_limit_token_refreshread 150 on every plan while the public page quoted 1800 per hour at the time (AR01c, 2026-09-08). If the config value is per hour those disagree by 12x; if it is per five minutes then 150 per five minutes is 1800 per hour and they agree. The run does not settle which unit the field carries, and the page has since been rewritten in terms ofauth.rate_limits.*keys with no numeric default for this one, so the comparison cannot be repeated against the live page.16- SMS caps, the per-minute MFA challenge limit, the web3 endpoints, the per-user OTP, signup-confirmation and password-reset windows, the email cap under custom SMTP or the Send Email hook, and the same burst behind a shared NAT address were not run.
Dropping the HTTP tier instead
Section titled “Dropping the HTTP tier instead”The other in-house option 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.17
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 | rate_limit_email_sent read as 2 from GET /v1/projects/{ref}/config/auth (S03, S14). W03 and S11 route around the limit rather than measuring it |
422 on a signup burst in the seconds after a switch was turned on | the setting has not propagated, and it is not a rate limit: anonymous sign-ins answered 60 x 422 until the run polled /auth/v1/settings for external.anonymous_users | measured AR01a, 2026-09-08 |
429 on a signup burst | the caller’s bucket empties at request 31 of 60 on the anonymous path, and the built-in email provider refuses the 3rd send inside the hour; forwarding a distinct Sb-Forwarded-For with a secret key moves the bucket onto that address | measured AR01b, AR02a, AR02b |
400 Email template modification is not available for free tier projects using the default email provider from PATCH /v1/projects/{ref}/config/auth | a new Free project on the default SMTP provider cannot write email template content, subjects or notification templates; custom SMTP fields lift it, and clearing them removes the custom templates | measured FE01, 2026-10-10; Pro control FE02 |
401 Invalid JWT or 401 Missing authorization header before the function runs | the Edge Function gateway under verify_jwt true: an expired, third-party or foreign bearer, or no credential; a bare sb_ key is not refused here | measured ER01, 2026-10-10 |
401 with x-supabase-server-error: INVALID_JWT or INVALID_CREDENTIALS from the function | the library refused: INVALID_JWT for a token that is not a project-issued ES256 one in user mode, INVALID_CREDENTIALS for a user JWT with no apikey in secret or publishable mode | measured ER01, 2026-10-10 |
What to do about it
Section titled “What to do about it”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). - After a
jwt_expchange, poll sign-ins until a token carries the new lifetime; the Management API readback ran about 10 s ahead of the first token, at a 10 s retry resolution (ER01).
Verifying inside the function
- Use
usermode only for project-issued ES256 tokens, and give third-party-issuer and legacy HS256 tokens a separate verifier;usermode refused both withINVALID_JWT(ER01). - Send the
apikeyheader with a user JWT insecretandpublishablemodes; without it the library answersINVALID_CREDENTIALS.userand['user','secret']ran the handler on the user JWT alone (ER01). - Set
verify_jwt = falsewhennonemode must answer a request with no credentials or with a bearer that is not a project JWT; the gateway refuses both first (ER01). - Do not depend on the exact expiry instant: the library accepted a token 2.9 s before
expand refused it 1.4 s after, and the 4.3 s between those probes was not resolved (ER01).
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). - To set claims over the wire, use
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). Readpg_default_aclon your own project first: an API-created project on 2026-10-10 still carried the legacyarwdDxtmdefault (DD01). - For OAuth client tokens, key policies on
auth.jwt() ->> 'client_id'and write the password-session path (nullclient_id) as its own case; the scope does not limit the Data API (K06).
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 plusauth.mfa_amr_claimsafterauth.sessions, 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, session-carry S04a, S04b). - Do not plan a move between projects on a shared signing key: a kid cannot be imported into a second project while another project holds it
in_use(other key states not run), and the same material under a new kid is refused (session-carry S01b, S01x, S01f, S01g). Expect the access token held at cutover to fail at the target (403 bad_jwtat Auth,401 PGRST301at PostgREST; S02a, S02b) until the client refreshes there (S02c, S02e). - For PBKDF2 sources, import passwordless and verify locally on first login (pbkdf2 reference).
Provider subject changes
- Treat an identity as the pair (provider, subject): a new subject is a stranger until a verified email or a rewrite links it (IT01a, IT01b).
- On a provider migration, rewrite
provider_idon the identity row and updateauth.users.email; the JSON copies and the user metadata repair themselves on the next sign-in, the primary email does not (ITL1b, ITL1c, IT01d). - Use a Before User Created hook to refuse unexpected new accounts, not to catch a migrated user: it does not run down the link path or the existing-user path (ITL3b, ITL3c, ITL3d).
- Harvest a transfer identifier from the provider’s endpoint while it is valid; a stored copy disappears on the first sign-in without it (ITL2b, ITL2c).
- Clean up by subject, not by email: a refused unverified-email sign-in leaves an identity row and a user row with
auth.users.emailNULL (IT01e).
Levers
verify_jwton an Edge Function checks the bearer at the gateway and passes a bare publishable or secret key; authorise inside the function or with RLS (L07, ER01).- On a Free project, add custom SMTP fields before writing auth email templates, and re-apply the templates after clearing them; on the default provider the Management API answers
400and a clear removes stored templates (FE01). - 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 account for |
| PostgREST and GoTrue verify the same key set | PostgREST rejects an HS256 token carrying any kid; the managed GoTrue accepts it. For an ES256 token the managed GoTrue selects by kid and refused key material it held only under another kid (S01g) | undocumented |
a private key you already have can be imported, and the kid header of the tokens you sign must be the same value you give when importing | a kid another project already holds is refused 409 “Signing key with kid … already exists”: on a second project in the same organization (S01b) and on a project in a different organization with different key material (S01x), while the first project held the kid in_use; both organizations were under one account, cross-account not run. | docs silent on kid uniqueness across projects |
security_refresh_token_reuse_interval is configurable | PATCH accepted and read back; the running service kept 10 s | propagation or clamp unknown |
| the token-endpoint limit is 1800 per hour | rate_limit_token_refresh read 150 on Free, Pro and Team alike (2026-09-08), and the two figures agree only if the field is per five minutes rather than per hour | unit of the config field was not settled by the run, and the page has since dropped the numeric default |
the package docs: disable the platform JWT check (verify_jwt = false) for publishable, secret or none | with verify_jwt true a bare publishable key, a bare secret key and the publishable key in both headers reached the handler (200); the gateway refused a missing credential and a bad bearer (2026-10-10, ER01) | the instruction is stricter than the cells needed |
| new projects get the Data API default that exposes no new table from 2026-05-30 | an API-created project on 2026-10-10 still had arwdDxtm default privileges for anon, authenticated and service_role (DD01) | creation path or gradual rollout, not separated |
None of the first ten rows had been filed with Supabase as of 2026-09-25. The last two rows come from the 2026-10-10 runs, whose RUNLOGs record no filing.
Reading the numbers
Section titled “Reading the numbers”- 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. - 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 in W01 and a Supabase project in W05). Neither run reproduced the other’s conditions, and the runs do not reconcile the two.
- 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. - Each negative claim (“not portable”, “cannot”) comes from a complete probe set or a cited source.
- The 2026-10-10 rows are small samples. ER01 is one project and one pass over 70 cells per target, its expiry offsets are on the probing machine’s clock, and the gateway’s refusal of a third-party token (120 s polled, then again in the matrix) is not separated into propagation and rule. K06 is n = 1 per cell. FE01 is three new Free projects (two runs for the notification-template and
smtp_host-only rows) and FE02 one Pro project. DD01 is one API-created project, one trial per probe. - The provider-subject section spans two load-bearing vantages and they are
not interchangeable: IT01 is the managed platform (2026-09-07, one micro
project), ITL1-ITL3 are a self-hosted GoTrue on a local rig (2026-09-11, one
run of 14 cases), and the reason the local rig stands in - four source files
identical between
v2.197.0andmasteron that day - is an argument about the code, not a measurement on the platform. The IT01 module was edited after its 2026-09-07 run, so that artifact carries neither the metadata measurements nor evidence that the refactor was behaviour-neutral; ITL1c measured the same question locally. Two labels in the ITL artifact are harness defaults rather than facts: the region readsap-southeast-1and the rig has none, and the lab-commit stamp resolves to nothing after a history rewrite.
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/; session-carry’s RUNLOG is pinned at 31b9102. The provider-subject section comes from identity-transfer: IT01 on the managed platform (2026-09-07, out/2026-09-07) and ITL1-ITL3 on a local self-hosted GoTrue (2026-09-11, out/2026-09-11).
| Claim | Status | How it was checked |
|---|---|---|
| The IP bucket trips at request 31 of 60, the built-in email cap refuses the 3rd send inside the hour, and both are identical on Free, Pro and Team | measured | AR01a, AR01b, AR02a, one throwaway project per plan, ap-southeast-1, 2026-09-08 |
Sb-Forwarded-For rebuckets the limit onto the forwarded address, and only with a secret key and forwarding enabled | measured | AR02a (forwarding off, 429 at 31 of 60), AR02b (on, 60 distinct addresses, no 429) |
| Sign-up latency is bcrypt-bound: p50 102/105/110ms sequential against 411/368/366ms at concurrency 8 | measured | AR03, POST /auth/v1/admin/users, one run per plan |
| GoTrue’s connection share is 10 of the project’s 60 | measured, read-only | AR04, config/auth against pg_settings.max_connections; recorded rather than asserted, since the run does not establish which plans change the pool figure |
| A new subject with a verified email matching one non-SSO user links to that user; a different email makes a new user | measured | identity-transfer IT01b, IT01c (2026-09-07, managed micro project, ap-southeast-1) |
Rewriting provider_id alone remaps the subject; identity_data.sub is not read | measured | identity-transfer ITL1b, ITL1c (2026-09-11, self-hosted supabase/auth:v2.197.0); IT01d on managed rewrote both |
A stored transfer_sub claim is never read and does not survive a sign-in without it | measured | identity-transfer ITL2a-ITL2c (2026-09-11) |
| The Before User Created hook runs on create only, and its payload carries the provider’s custom claims | measured | identity-transfer ITL3a-ITL3e (2026-09-11); managed hook config not probed |
An unverified matching email is refused provider_email_needs_verification and leaves an identity row and a NULL-email user row | measured | identity-transfer IT01e (2026-09-07) |
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) |
A kid held in_use by one project is refused 409 on a second project, in the same organization and in another (one account); the same material under a fresh kid is refused by target Auth (403 bad_jwt, kid named) and target PostgREST (401 PGRST301, kid lookup inferred) | measured | session-carry S01b, S01x, S01c, S01e-S01g, one run; RUNLOG, out/2026-09-25 |
| 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; a cached Hyperdrive binding is blind to the claims GUC while the cache-disabled binding answers per-claims | 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 |
Without auth.mfa_amr_claims an aal2 session refreshes to aal1 with no amr at the target; with it, aal2 with amr password+totp; the source control stays aal2 | measured | session-carry S04a, S04b, S04c, one run, out/2026-09-25 |
After the copy, the held access token is refused at the target (Auth 403 bad_jwt, PostgREST 401 PGRST301); the held refresh token refreshes there (200) and the refreshed access token is accepted at target Auth /user (200) | measured | session-carry S02a-S02c, S02e, one run, out/2026-09-25 |
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, and the W14 manual drill (RUNLOG, not a module) |
| 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 |
With verify_jwt true, a bare publishable key, a bare secret key and the publishable key in both headers reach the handler; the gateway refuses a missing credential and an expired, third-party or foreign bearer; it passes a legacy HS256 token the library refuses and refused a third-party token PostgREST accepted | measured | ER01, @supabase/server 1.9.1, one Pro project, ap-southeast-1, 2026-10-10, out/2026-10-10; third-party refusal polled 120 s after registration, then again in the matrix; propagation versus rule not settled |
user mode accepts only project-issued ES256 tokens; a bad JWT in ['user','secret'] is refused, not downgraded; a user JWT with no apikey in secret or publishable mode answers INVALID_CREDENTIALS; expiry accepted 2.9 s before and refused 1.4 s after exp | measured | ER01, Edge Function with verify_jwt false, probes about 4.2 s apart on the probing machine’s clock |
Edge Function runtime variables: plural key variables and SUPABASE_JWKS set; singular key variables and SUPABASE_JWKS_URL absent | measured | ER01 setup facts, 2026-10-10 |
jwt_exp readback 300 while the first sign-in still got 3600 s; the second, 10 s later, got 300 s | measured | ER01 setup facts, one project, 10 s retry step |
App MCP server block: 4 of 4 OAuth tokens carry client_id, aud=authenticated, ES256, 3600 s; RLS on client_id isolates two clients and a password session; publishable key, legacy anon JWT and garbage refused 401; the scope does not limit the Data API (200) | measured | K06, one new project, Team organisation, ap-southeast-1, 2026-10-10 |
New Free project on default SMTP: 6 of 6 template content fields, all subjects and the seven notification templates refused 400; custom SMTP fields lift it; clearing them removes the custom templates; smtp_host alone does not lift it; Pro control 6 of 6 accepted | measured | FE01 (3 Free projects), FE02 (1 Pro project), 2026-10-10 |
| Free projects created before 2026-06-03, email delivery, the Dashboard path | not measured | FE01 and FE02 exercised the Management API on new projects only |
An API-created project still carries arwdDxtm default privileges for anon, authenticated and service_role on 2026-10-10; after the notice’s revoke a new table answers 401 (anon, publishable) or 403 (service_role, secret key) with 42501 | measured | DD01, one Pro project, Postgres 17.11, 2026-10-10; re-run after 2026-10-30 not done |
| 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 |
Modules
Section titled “Modules”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.
- Auth providers on Supabase - custom OIDC providers, passkeys and SAML SSO, the sign-in methods this doc leaves out.
- Auth modes for
@supabase/server- the 70-cell credential matrix behind Verifying inside the function. - 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
-
Supabase, “docs/auth-modes.md,” supabase/server on GitHub, package version 1.9.1. https://github.com/supabase/server ↩ ↩2
-
Erfi Anugrah, “RLS without Supabase Auth: JWT claims over the wire,” Erfi’s Lexicanum. https://erfi.dev/reference/rls-without-supabase-auth/ ↩
-
Supabase, “Breaking Change: Tables not exposed to Data and GraphQL API automatically,” supabase GitHub Discussions #45329, 2026-04-28. https://github.com/orgs/supabase/discussions/45329 ↩
-
Supabase, “Build anything: Supabase from code, and an MCP server for your app,” Supabase Blog, 2026-10-02. https://supabase.com/blog/select-2026-build-anything ↩
-
Supabase, “Supabase Middleware 1.0,” Supabase Changelog, 2026-09-30. https://supabase.com/changelog/supabase-middleware-1-0 ↩
-
Erfi Anugrah, “Migrating PBKDF2 password hashes into Supabase Auth,” Erfi’s Lexicanum. https://erfi.dev/reference/pbkdf2-supabase-auth-migration/ ↩
-
Supabase, “Identity linking,” Supabase Docs. https://supabase.com/docs/guides/auth/auth-identity-linking ↩
-
Erfi Anugrah, “Locking down Supabase: the database is the security boundary,” Erfi’s Lexicanum. https://erfi.dev/reference/supabase-data-surface-lockdown/ ↩
-
Supabase, “Changes to Email Template Customisation on Free Tier,” supabase GitHub Discussions #46599, 2026-06-03. https://github.com/orgs/supabase/discussions/46599 ↩
-
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 ↩
-
Supabase, “Rate limits,” Supabase Docs. https://supabase.com/docs/guides/auth/rate-limits ↩ ↩2 ↩3
-
Erfi Anugrah, “Run your own PostgREST against a Supabase project,” Erfi’s Lexicanum. https://erfi.dev/guides/supabase-own-postgrest/ ↩