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.

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_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.
  • An Edge Function’s verify_jwt is a gateway check on the bearer, and it does not reject sb_ keys. With verify_jwt true 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/server library’s user mode 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_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. 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.
  • 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 if auth.mfa_amr_claims is 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 429 at 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’s max_connections of 60.
  • 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, plus auth.mfa_amr_claims after auth.sessions so an aal2 session stays aal2, resync the sequence, retire the source identityPromoting a tenant; session-carry S04 for auth.mfa_amr_claims
Bring users from a PBKDF2 systemPasswordless import plus a local verifier in an Edge FunctionMigrating 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 tokensthis doc, Verifying inside the function; Auth modes for @supabase/server
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, 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 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
ES256 token from another project, whose private key material the verifying project holds under a different kid401 PGRST301; kid lookup inferred, not shown403 bad_jwt “unrecognized JWT kid … for algorithm ES256”session-carry S01f, S01g, one run
jwt_exp changen/a - exp is minted by the issuerhonours 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 readbackW03; 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

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

  • user mode 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 refused 401 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 in apikey, were refused INVALID_JWT; a request carrying only the secret key still matched secret (200).
  • A valid user JWT with no apikey, sent to secret or publishable mode, returns 401 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_exp 300 s, probed about every 4.2 s: accepted 2.9 s before exp, refused INVALID_JWT 1.4 s after it (12 probes from +1.4 s onward refused). The probes are 4.3 s apart across exp and 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_KEYS and SUPABASE_SECRET_KEYS (each carrying one key named default) and SUPABASE_JWKS (one ES256 key) were set. The singular SUPABASE_PUBLISHABLE_KEY and SUPABASE_SECRET_KEY, and SUPABASE_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.

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.

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

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

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). 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 doneiap-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.

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_id equal to the registered client, aud=authenticated, role=authenticated, scope=email, header alg ES256 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_id null, 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 401 by the function.
  • The scope identifies and does not limit. The same scope=email token sent to the Data API (/rest/v1/purchase_requests) answered 200 with 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.

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_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.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).

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 arrivesWhat Auth does
new subject, verified email matching exactly one non-SSO userlinks to the existing user: same user id in the token, a second row in auth.identities (IT01b)
new subject, a different emaila new user, none of the old rows (IT01c)
new subject, email matching but email_verified = falseno 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. DetermineAccountLinking opens on the identity pair, so a claim naming the old subject is stored and never read. A transfer_sub-shaped claim was written to both the identity and the user (identity_data.custom_claims and raw_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, because identity_data is replaced wholesale and the top-level custom_claims map 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, no error_code, and the hook’s own digest of its payload in the message - user_metadata keys including custom_claims, with transfer_sub matching what the issuer sent (ITL3d; the same shape with a claim the hook accepts was created, ITL3e). No auth.users row 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.

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 disabling them sets a posture and locks no PAT holder outL05, 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 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_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.11

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

StateTemplate writesMeasured
Free, default SMTP6 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 200FE01
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 serverFE01
Free, smtp_host alonerefused 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 runsFE01
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 runsFE01
Pro, default SMTP6 of 6 content PATCHes, subjects and notification templates all 200 and persistedFE02

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

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.

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.

Every probe below was measured and passed.

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.

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.

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.

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.

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.

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.

BoundaryDocumentedMeasured 2026-09-08Module
Anonymous sign-in, per IP, first cutbucket capacity equal to the configured limit60 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 documentedAR01a
Anonymous sign-in, per IP, countedcapacity 30429 at request 31 of 60 on a fresh bucketAR02a
Email sends, built-in provider, per project2 per hourthe 3rd signup inside the hour refused 429 over_email_send_rate_limit "email rate limit exceeded"AR01b
Config defaults behind those bucketsnot stated as absolute valuesanonymous 30, email 2, otp 30, verify 30, token_refresh 150, sms 30AR01c

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.

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.

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):

PathSequentialConcurrency 8
Pro102411
Team105368
Free110366

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.

  • 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_refresh read 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 of auth.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.

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.

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 pathrate_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 onthe 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_usersmeasured AR01a, 2026-09-08
429 on a signup burstthe 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 addressmeasured 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/autha 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 templatesmeasured FE01, 2026-10-10; Pro control FE02
401 Invalid JWT or 401 Missing authorization header before the function runsthe Edge Function gateway under verify_jwt true: an expired, third-party or foreign bearer, or no credential; a bare sb_ key is not refused heremeasured ER01, 2026-10-10
401 with x-supabase-server-error: INVALID_JWT or INVALID_CREDENTIALS from the functionthe 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 modemeasured ER01, 2026-10-10

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).
  • After a jwt_exp change, 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 user mode only for project-issued ES256 tokens, and give third-party-issuer and legacy HS256 tokens a separate verifier; user mode refused both with INVALID_JWT (ER01).
  • Send the apikey header with a user JWT in secret and publishable modes; without it the library answers INVALID_CREDENTIALS. user and ['user','secret'] ran the handler on the user JWT alone (ER01).
  • Set verify_jwt = false when none mode 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 exp and 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_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).
  • To set claims over the wire, use 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). Read pg_default_acl on your own project first: an API-created project on 2026-10-10 still carried the legacy arwdDxtm default (DD01).
  • For OAuth client tokens, key policies on auth.jwt() ->> 'client_id' and write the password-session path (null client_id) as its own case; the scope does not limit the Data API (K06).

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 plus auth.mfa_amr_claims after auth.sessions, 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, 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_jwt at Auth, 401 PGRST301 at 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_id on the identity row and update auth.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.email NULL (IT01e).

Levers

  • verify_jwt on 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 400 and 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 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 account for
PostgREST and GoTrue verify the same key setPostgREST 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 importinga 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 configurablePATCH accepted and read back; the running service kept 10 spropagation or clamp unknown
the token-endpoint limit is 1800 per hourrate_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 hourunit 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 nonewith 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-30an 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.

  • 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 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.
  • 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.0 and master on 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 reads ap-southeast-1 and the rig has none, and the lab-commit stamp resolves to nothing after a history rewrite.

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

ClaimStatusHow 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 TeammeasuredAR01a, 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 enabledmeasuredAR02a (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 8measuredAR03, POST /auth/v1/admin/users, one run per plan
GoTrue’s connection share is 10 of the project’s 60measured, read-onlyAR04, 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 usermeasuredidentity-transfer IT01b, IT01c (2026-09-07, managed micro project, ap-southeast-1)
Rewriting provider_id alone remaps the subject; identity_data.sub is not readmeasuredidentity-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 itmeasuredidentity-transfer ITL2a-ITL2c (2026-09-11)
The Before User Created hook runs on create only, and its payload carries the provider’s custom claimsmeasuredidentity-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 rowmeasuredidentity-transfer IT01e (2026-09-07)
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)
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)measuredsession-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 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-ops; a cached Hyperdrive binding is blind to the claims GUC while the cache-disabled binding answers per-claimsmeasuredrls-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
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 aal2measuredsession-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)measuredsession-carry S02a-S02c, S02e, one run, out/2026-09-25
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, and the W14 manual drill (RUNLOG, not a module)
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
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 acceptedmeasuredER01, @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 expmeasuredER01, 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 absentmeasuredER01 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 smeasuredER01 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)measuredK06, 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 acceptedmeasuredFE01 (3 Free projects), FE02 (1 Pro project), 2026-10-10
Free projects created before 2026-06-03, email delivery, the Dashboard pathnot measuredFE01 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 42501measuredDD01, one Pro project, Postgres 17.11, 2026-10-10; re-run after 2026-10-30 not done
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
ModuleExperimentTestArtifact
AR01aauth-rate-limitsar01-endpoint-limits.tsnone published
AR01bauth-rate-limitsar01-endpoint-limits.tsnone published
AR01cauth-rate-limitsar01-endpoint-limits.tsnone published
AR02aauth-rate-limitsar02-forwarded-for.tsnone published
AR02bauth-rate-limitsar02-forwarded-for.tsnone published
AR03auth-rate-limitsar03-signup-hashing.tsnone published
AR04auth-rate-limitsar04-connection-share.tsnone published
C01rls-wire-claimsc01-wire-claims.tsnone published
C02rls-wire-claimsc02-gotrue-claims.tsnone published
C02tenant-consolidationc02-email-collision.tsnone published
C03rls-wire-claimsc03-hyperdrive-cache.tsnone published
C03tenant-consolidationc03-users-merge-api.tsnone published
L04iap-lockdownl04-auth-knobs.tsnone published
L05iap-lockdownl05-key-revocation.tsnone published
L07iap-lockdownl07-ef-verify-jwt.tsnone published
L08iap-lockdownl08-grant-lockdown.tsnone published
L09iap-lockdownl09-prerequest-and-api-enum.tsnone published
L10iap-lockdownl10-tpa-iap-issuer.tsnone published
L11iap-lockdownl11-worker-access-proxy.tsnone published
L13iap-lockdownl13-custom-domain.tsnone published
P01tenant-promotionp01-promotion-follows.tsnone published
P02tenant-promotionp02-mfa-survives.tsnone published
P03tenant-promotionp03-retire-source-identity.tsnone published
R01key-rotationr01-rate-limit.tsout/2026-08-25
R02key-rotationr02-no-reresolve.tsout/2026-08-25
R03key-rotationr03-revoked-key.tsout/2026-08-25
S01bsession-carrys01-shared-key.tsout/2026-09-25
S01csession-carrys01-shared-key.tsout/2026-09-25
S01dsession-carrys01-shared-key.tsout/2026-09-25
S01esession-carrys01-shared-key.tsout/2026-09-25
S01fsession-carrys01-shared-key.tsout/2026-09-25
S01gsession-carrys01-shared-key.tsout/2026-09-25
S01xsession-carrys01-shared-key.tsout/2026-09-25
S02security-lockdowns02-network-restrictions.tsnone published
S02asession-carrys02-rows-keys-differ.tsout/2026-09-25
S02bsession-carrys02-rows-keys-differ.tsout/2026-09-25
S02csession-carrys02-rows-keys-differ.tsout/2026-09-25
S02esession-carrys02-rows-keys-differ.tsout/2026-09-25
S03security-lockdowns03-auth-hardening.tsnone published
S04security-lockdowns04-self-hosted-postgrest.tsnone published
S04asession-carrys04-aal2-carry.tsout/2026-09-25
S04bsession-carrys04-aal2-carry.tsout/2026-09-25
S04csession-carrys04-aal2-carry.tsout/2026-09-25
S06security-lockdowns06-postgrest-role.tsnone published
S10security-lockdowns10-socket-lock.tsnone published
S11security-lockdowns11-auth-efficacy.tsnone published
S13security-lockdowns13-column-grants.tsnone published
S14security-lockdowns14-auth-switch-on.tsnone published
S15security-lockdowns15-storage-realtime-reach.tsnone published
SH01self-hosted-authsh01-privilege-map.tsout/2026-09-02
SH01cself-hosted-authsh01-privilege-map.tsout/2026-09-02
SH02self-hosted-authsh02-shared-schema-trust.tsout/2026-09-02
SH02dself-hosted-authsh02-shared-schema-trust.tsout/2026-09-02
SH02fself-hosted-authsh02-shared-schema-trust.tsout/2026-09-02
SH03self-hosted-authsh03-refresh-takeover.tsout/2026-09-02
SH04self-hosted-authsh04-verify-managed-key.tsout/2026-09-02
SH04cself-hosted-authsh04-verify-managed-key.tsout/2026-09-02
SH05self-hosted-authsh05-revoke-legacy-key.tsout/2026-09-02
SH06self-hosted-authsh06-own-key-issuer.tsout/2026-09-02
SH06eself-hosted-authsh06-own-key-issuer.tsout/2026-09-02
W01edge-resiliencew01-jwt-skew.tsout/2026-08-16, out/2026-08-17
W03edge-resiliencew03-jwt-exp.tsout/2026-08-16, out/2026-08-17
W05edge-resiliencew05-standby-replication.tsout/2026-08-16, out/2026-08-17
W07edge-resiliencew07-breakglass-mint.tsout/2026-08-16, out/2026-08-17
W08edge-resiliencew08-refresh-race.tsout/2026-08-16, out/2026-08-17
W09edge-resiliencew09-auth-replication.tsout/2026-08-16, out/2026-08-17
W14edge-resilienceRUNLOG-
W17edge-resiliencew17-config-parity.tsout/2026-08-16, out/2026-08-17
X01cross-project-authx01-tpa-shapes.tsnone published
X02cross-project-authx02-token-portability.tsnone published
X03cross-project-authx03-refresh-to-issuer.tsnone published
IT01aidentity-transferit01-identity-transfer.tsout/2026-09-07
IT01bidentity-transferit01-identity-transfer.tsout/2026-09-07
IT01cidentity-transferit01-identity-transfer.tsout/2026-09-07
IT01didentity-transferit01-identity-transfer.tsout/2026-09-07
IT01eidentity-transferit01-identity-transfer.tsout/2026-09-07
ITL1bidentity-transferitl1-remap-stored-copies.tsout/2026-09-11
ITL1cidentity-transferitl1-remap-stored-copies.tsout/2026-09-11
ITL2aidentity-transferitl2-custom-claim-local.tsout/2026-09-11
ITL2bidentity-transferitl2-custom-claim-local.tsout/2026-09-11
ITL2cidentity-transferitl2-custom-claim-local.tsout/2026-09-11
ITL3bidentity-transferitl3-hook-local.tsout/2026-09-11
ITL3cidentity-transferitl3-hook-local.tsout/2026-09-11
ITL3didentity-transferitl3-hook-local.tsout/2026-09-11
ITL3eidentity-transferitl3-hook-local.tsout/2026-09-11
ER01edge-runtime-auther01-server-auth-matrix.tsout/2026-10-10
FE01free-email-templatesfe01-free-default-smtp.tsrun-2026-10-10T07-20-50-952Z, run-2026-10-10T07-22-08-083Z, run-2026-10-10T07-23-12-964Z
FE02free-email-templatesfe02-paid-control.tsrun-2026-10-10T07-22-08-083Z
K06governed-starter-kitk06-mcp-block.tsnone published
DD01data-api-defaultsdd01-default-grants.tsrun-2026-10-09T23-58-24-538Z, run-2026-10-10T00-00-27-688Z
  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. Supabase, “docs/auth-modes.md,” supabase/server on GitHub, package version 1.9.1. https://github.com/supabase/server ↩ ↩2

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

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

  6. 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 ↩

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

  8. Supabase, “Supabase Middleware 1.0,” Supabase Changelog, 2026-09-30. https://supabase.com/changelog/supabase-middleware-1-0 ↩

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

  10. Supabase, “Identity linking,” Supabase Docs. https://supabase.com/docs/guides/auth/auth-identity-linking ↩

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

  12. Supabase, “Changes to Email Template Customisation on Free Tier,” supabase GitHub Discussions #46599, 2026-06-03. https://github.com/orgs/supabase/discussions/46599 ↩

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

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

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

  16. Supabase, “Rate limits,” Supabase Docs. https://supabase.com/docs/guides/auth/rate-limits ↩ ↩2 ↩3

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