Promoting a tenant to its own Supabase project
A tenant on the shared tier starts paying, or needs isolation the shared tier does not offer. This guide moves it onto its own Supabase project - copying its schema and rows - without invalidating the session it already holds.
It assumes you already have
a shared tier running: a hub project issuing every
tenant’s token, and a shared project separated by RLS on a tenant_id claim that
tenant cannot write. That guide covers the trust wiring this one depends on and is
the prerequisite for it.
Where should a tenant live covers when
a tenant is worth promoting at all, and what the dedicated project then costs.
What you will build
Section titled “What you will build”- One dedicated project that the tenant moves into, wired to trust the same hub.
- A promotion path that copies the tenant’s rows without invalidating its existing session.
- Optionally, a copy of the tenant’s identity itself, so the dedicated project can issue its own tokens instead of only verifying the hub’s.
- The same identity copy applied to every user of an app, moving the whole app from a source project to a target project, and what each piece of a live session does on the way (Part 5).
Prerequisites: a Personal Access Token in SUPABASE_ACCESS_TOKEN, the service_role
key for the shared and dedicated projects, jq, and the shared tier from the guide
above already running.
Measurement provenance
Section titled “Measurement provenance”Measured 2026-08-03 against live Supabase projects in ap-southeast-1 on Micro
compute, Vault 0.3.1, with two tenants and three rows - the same fixture the
shared-tenancy guide measures against. Claims marked measured in the evidence table
were executed and captured.
Three further runs, all on 2026-08-04, extend that baseline. A client reading its
{ref, key} from a discovery endpoint carried a live promotion with zero
re-authentication. The identity-migration copy was extended to auth.mfa_factors
and to retiring the source identity. And a third rotation run re-read both key sets
at every probe for 37 minutes, separating the issuer’s publication lag from the
consumer’s cache lag. Timings quoted for those three runs come from the run itself.
The rotation modules ran live again on 2026-08-25 (key-rotation R01-R03, two throwaway projects, a 20-minute window; published artifacts). That run reproduced the consumer-cache result, refuted the standby rate limit, and did not re-confirm the revoked-key result. Part 4 carries both dated runs.
Two session-carry runs on 2026-09-25 moved existing sessions between two projects.
The 02:55 UTC run covered S00-S04 and the 02:56 UTC run repeated S01, both on two
Micro projects in ap-southeast-1 in one organization. S01x also used a free-plan
project in a second organization under the same account
(published artifacts).
They add the aal2 result to Part 3 and are the whole of Part 5.
Constants
Section titled “Constants”| Fact | Value |
|---|---|
| JWKS refresh | cached in project config. On 2026-08-03/04 a trusting project did not accept new-key tokens within 20 minutes of a rotation and honoured a revoked key for 15, with its cached kid set still unchanged across 282 probes in 37 minutes. The 20-minute result reproduced live on 2026-08-25 over 116 probes; the revoked-key result was not re-confirmed on that run. See Part 4: rotating the hub’s key |
Architecture overview
Section titled “Architecture overview”The hub issues every tenant’s token; the shared and the dedicated project each
resolve the hub’s key set once and verify against it, and the tenant_id claim
in the token is what RLS scopes the read by.
Part 1: promote the tenant to its own project
Section titled “Part 1: promote the tenant to its own project”The tenant is paying. Give it a dedicated project without disturbing its session.
1. Provision and wire it. Create the project, apply the same schema, and register the hub’s JWKS on it, the same way as wiring the trust on the shared project. The schema stays identical, RLS included: the policy is harmless when the table holds one tenant, and identical schemas mean promotion is a data copy rather than a migration.
2. Confirm the token already works there. Before moving any data, the tenant’s existing token should be accepted by the new project and return nothing:
curl -s "https://<dedicated-ref>.supabase.co/rest/v1/items?select=tenant_id" \ -H "apikey: $DEDICATED_ANON" -H "Authorization: Bearer $TOKEN_A" | jq -c# [] <- accepted, no rows yet. PGRST301 here means the JWKS wiring is wrong.Distinguishing [] from PGRST301 is the whole check. Do it before the copy, while
an empty result is still expected.
3. Copy that tenant’s slice. Filtered dump per tenant-scoped table:
for t in items other_table; do psql "$SHARED_DB_URL" -c "\copy (select * from public.$t where tenant_id = 'tenant-a') to stdout" \ | psql "$DEDICATED_DB_URL" -c "\copy public.$t from stdin"doneReset sequences afterward, or the first insert on the target collides:
select setval('public.items_id_seq', coalesce((select max(id) from public.items), 1));The collision this prevents is measured in the consolidation direction
(tenant-consolidation C04: 23505 on the first write after the merge); the promotion
modules resync auth.refresh_tokens_id_seq only.
Storage objects are not in the database. Copy them separately, per tenant prefix.
4. Repoint. Update your registry so tenant-a resolves to the dedicated ref, and
verify with the same unchanged token:
curl -s "https://<dedicated-ref>.supabase.co/rest/v1/items?select=tenant_id,body&order=id" \ -H "apikey: $DEDICATED_ANON" -H "Authorization: Bearer $TOKEN_A" | jq -c# [{"tenant_id":"tenant-a","body":"a-row-1"},{"tenant_id":"tenant-a","body":"a-row-2"}]No re-login or token reissue. The issuer never changed.
5. Reap. The rows stay on the shared instance until you remove them - promotion is not destructive by itself, so keep them through a validation window, then:
delete from public.items where tenant_id = 'tenant-a';Part 2: how clients learn where a tenant lives
Section titled “Part 2: how clients learn where a tenant lives”A tenant’s project ref changes at promotion, and the ref appears in the URL the client calls. Two ways to absorb that:
- A discovery endpoint. The client asks your control plane for its current ref and publishable key, caches the answer, and re-fetches on failure. It is a config lookup; nothing sits between the client and the project.
- A gateway. Clients hit one stable host and it forwards to the right project, injecting keys, so they never see a ref at all. More moving parts and it sits in the data path.
The token is valid against both projects either way, which removes the gateway’s token-reissue job.
The 2026-08-04 run showed the discovery endpoint is enough and the gateway is not
needed. A client that read its {ref, publishable key} from a registry at request
time followed a tenant through a promotion with zero password logins - the refresh
token it already held minted a session at the dedicated project, and the read
returned the tenant’s rows there. The other tenant, untouched, still resolved to the
shared project and still read its own row, so the flip is per-tenant. The only hosts
contacted were the two projects: nothing terminated a request in between.
That leaves ref-hiding as the gateway’s last job, and it is a project setting rather than a component. A vanity subdomain activates over the Management API and gives the tenant a hostname with no project ref in it:
curl -sX POST -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \ -H 'Content-Type: application/json' -d '{"vanity_subdomain":"acme-labs"}' \ "https://api.supabase.com/v1/projects/$REF/vanity-subdomain/check-availability"# 201 {"available":true} <- 201, not 200curl -sX POST -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \ -H 'Content-Type: application/json' -d '{"vanity_subdomain":"acme-labs"}' \ "https://api.supabase.com/v1/projects/$REF/vanity-subdomain/activate"The call wants a bare label rather than a hostname; a dotted value is rejected with 400 before availability is evaluated. The custom domains doc says vanity subdomains are configurable “via the CLI only”, but the Management API accepts them directly.
The same run showed that after the move the shared project keeps serving the tenant’s original token. Promotion is a copy, so both placements answer until the source identity is retired (Part 3).
Part 3: promotion moves the data, not the identity
Section titled “Part 3: promotion moves the data, not the identity”A promoted tenant keeps its session through the trust wiring, and still depends on the hub to mint its tokens.
| Measured | |
|---|---|
| Refresh the hub-issued token at the dedicated project | 400 refresh_token_not_found - “Invalid Refresh Token: Refresh Token Not Found” |
| Refresh it at the hub | new access token, still accepted by the dedicated project |
| Access-token lifetime | 3600 s (exp - iat, and the hub’s jwt_exp) |
| Delete the hub, then read at the dedicated project with a live token | HTTP 200 for the whole observation window; the integration still holds resolved_jwks |
| Delete the hub, then refresh there | 410 Gone, then the host stops resolving |
Trust makes the dedicated project a verifier of the hub’s signatures; it issues no tokens of its own. Verification does not need the hub to exist - the resolved key is held project-side, and a deleted hub’s tokens kept working. Minting does. So the hub is on the availability critical path for every tenant on every project that trusts it, and the blast radius of losing it is one access-token lifetime: an hour on defaults, after which nobody can renew and no dedicated project can recover alone.
Three ways out. Keep the hub on the critical path deliberately and give it the availability that implies; put issuance in a real external IdP so both tiers are verifiers; or move the tenant’s identity into its own project at promotion time. The third one is a runbook rather than an architecture change, so it is the one tested below, and it costs no re-login at all.
Migrating the identity, so the dedicated project issues its own tokens
Section titled “Migrating the identity, so the dedicated project issues its own tokens”Nothing in Supabase’s docs sanctions writing into the auth schema, so this is
unsupported. It was measured on throwaway projects and needs fewer tables than
expected.
Copying one table is enough to break the coupling. With a single auth.users row
copied and no third-party-auth integration on the target at all, the tenant logs in
at its own project, the token it gets back has iss pointing at that project,
app_metadata.tenant_id survives the copy, the RLS read returns its rows, and the
project refreshes its own tokens. This is identity migration: the trust wiring
carries a tenant until it is promoted, and the copied identity carries it after.
Carrying the session is why this is a SQL copy rather than
POST /auth/v1/admin/users: that endpoint mints a user but cannot carry the
refresh token the user is already holding.
Moving users between projects
sets this against the consolidation direction, which accepts a sign-in and gets
the supported endpoint in exchange.
Copying four tables also carries the session. Add
auth.identities, auth.sessions and auth.refresh_tokens, in that order after
auth.users for the foreign keys, and the tenant’s existing refresh token works
against the new project. No re-login anywhere.
-- On the target, per table, in FK order: users, identities, sessions, refresh_tokens,-- then mfa_factors and mfa_amr_claims (mfa_amr_claims references auth.sessions).-- One user's rows at the source: users by id, refresh_tokens by user_id::text,-- mfa_amr_claims by session_id in (select id from auth.sessions where user_id = '<uid>'),-- the rest by user_id.-- `select *` does NOT work - see Gotchas and lessons learned.do $mig$declare cols text;begin select string_agg(quote_ident(column_name), ',' order by ordinal_position) into cols from information_schema.columns where table_schema = 'auth' and table_name = 'users' and is_generated = 'NEVER'; execute format( 'insert into auth.users (%s) select %s from json_populate_recordset(null::auth.users, $1::json)', cols, cols) using $j$<rows as json from the source>$j$::json;end $mig$;
-- Then, before the tenant's next refresh:select setval('auth.refresh_tokens_id_seq', (select coalesce(max(id), 1) from auth.refresh_tokens), true);Two of the three unexercised parts were measured on 2026-08-04, on three runs that agreed.
MFA travels. A time-based one-time password (TOTP) factor enrolled and verified on
the hub arrives at the target already status: verified when auth.mfa_factors is
copied alongside the other four tables, and the same secret produces a code that
verifies there for an aal2 session. Without this, the zero-re-login result would
have excluded every account that had enrolled a factor, and those are the accounts
most likely to notice being logged out.
The factor travelling does not carry a session’s assurance level with it. In
session-carry S04a on 2026-09-25, only users, identities, sessions, refresh_tokens
and mfa_factors were copied. A session that was already aal2 at the source came
back aal1 on its first refresh at the target, with no amr methods (amr_after
none). The source held 2 auth.mfa_amr_claims rows for each user, and that table is keyed by
session, so the five-table copy leaves them behind. Copying auth.mfa_amr_claims
after auth.sessions kept aal2, with amr password+totp (S04b). The control,
S04a’s refresh token presented at the source after it had been spent at the target, stayed aal2 (S04c).
An app whose RLS or routes require aal2 would then shut those users out of
MFA-gated paths until they verify a factor again (inferred, not probed).
Retiring the source identity is one call, and it closes both issuing paths. After
DELETE /auth/v1/admin/users/{id} on the hub, the hub refuses a password grant with
invalid_credentials and the previously issued refresh token with
refresh_token_not_found, while the dedicated project carries on unaffected. Until
you make that call, two projects can issue for the same tenant. The call does not
move the data: the tenant’s rows stay on the shared instance, so reaping them is a
separate, deliberate step.
OAuth and SAML identities are still not exercised. That needs a real external provider, and a lab that fakes one is measuring the fake.
Part 4: rotating the hub’s key is a maintenance window
Section titled “Part 4: rotating the hub’s key is a maintenance window”Every project that trusts the hub caches the hub’s key set, and that cache makes rotation slow in both directions.
| Step | Measured |
|---|---|
| Create a standby signing key | 2026-08-04: rate limited on a fresh project - Please wait until <ISO8601>, 144 s, 127 s and 131 s across three runs. 2026-08-25, live: refuted - a POST /v1/projects/{ref}/config/auth/signing-keys with {algorithm} while a standby was already present answered 422 "already has a signing key in standby" (R01b) and a create with none present answered 201 at once (R03c); the constraint is one standby at a time, with no wait |
PATCH standby -> in_use | previous key moves to previously_used automatically |
Hub starts issuing new-kid tokens | within 5 s |
| Trusting project accepts a new-key token | never within 20 minutes - 401 on all 80 polls (2026-08-03/04); 401 across 116 probes in 20 minutes on 2026-08-25 |
| Old token after rotation | still accepted |
Old token after that key is revoked | still accepted for 15 minutes - 200 on all 60 polls (2026-08-03/04; not re-confirmed on 2026-08-25, see below) |
The mechanism is visible in the API while it happens: the integration’s
resolved_at stays frozen at its pre-rotation value with only the old kid in
resolved_jwks.
A third run on 2026-08-04 re-read both key sets at every probe and separated two lags
the first runs could only observe stacked. The hub published the new kid in its own
JWKS in about 7 minutes; on the 2026-08-25 live run it took 241 s. The trusting
project’s cached set held exactly one kid on all 282 probes and never re-resolved
in the 37 minutes after the rotation. So the window is the consumer’s cache rather
than the issuer’s publication. That gives the DELETE + re-POST mitigation a
second way to fail; the first, in Gotchas and lessons learned,
is that a consumer forced to re-resolve early would re-cache a key set that did not
yet list the new key. The same run re-read each key’s status immediately before each
request, so “revoked but still honoured” is measured on both sides rather than
inferred from a PATCH that returned earlier.
The 2026-08-25 live run did not re-confirm the revoked-key row. Its module order meant
the trusting project never cached the revoked key, so that key’s tokens were refused
for staleness rather than for revocation, and the 15-minute figure stands on the
2026-08-03/04 runs alone. The same run found that a fresh integration created while
the hub’s JWKS already advertised the new key still served the original kid set, so
the consumer side alone defeats re-creation, whatever the issuer publishes. The
signing-key surface also moved between the two runs: the keys are managed at
/v1/projects/{ref}/config/auth/signing-keys with a PAT, and the project’s
/auth/v1/admin/signing-keys is a 404.
Supabase’s signing-keys documentation says asymmetric revocation “is automatic via the key discovery endpoint”. That holds for the issuing project and not for a third-party consumer inside its cache window. During an incident the hub can rotate and revoke in seconds, while the trusting project kept accepting the revoked key for all 15 minutes measured, rejected its replacement for all 20, and did not re-resolve across 37.
New sessions break first. Anyone who signs in or refreshes after the rotation gets a token no trusting project will accept, while everyone holding an older token carries on. The usual rollout shape is the reverse, where new code paths fail and old ones keep working; here the fresh credential is the broken one.
Plan a hub rotation as a scheduled window with sessions drained, or avoid the hub pattern where key rotation has to be fast. Nothing measured here makes it quick.
Part 5: moving a whole app between two projects
Section titled “Part 5: moving a whole app between two projects”Promotion moves one tenant. The same copy moves every user of an app from a source
project to a target project, and then nobody stays on the source: every session a
browser holds at cutover has to survive on the target or end in a sign-in. The plan
has three parts: import one signing key into both projects, copy each user’s auth
rows, and pin the client’s storageKey. Each part moves a different piece of the
session. Session-carry ran the first two; the third needs a browser (see
What was not run).
One signing key cannot serve both projects
Section titled “One signing key cannot serve both projects”Only ES256 signing keys were probed, imported through
POST /v1/projects/{ref}/config/auth/signing-keys with a private_jwk; sharing the
legacy HS256 JWT secret between projects was not tried.
| Probe (2026-09-25, 02:56 UTC run) | Result |
|---|---|
Import a private ES256 JWK into the source and promote it to in_use (S01a) | import 201, kid preserved, promote 200 |
| The same JWK, same kid, into the target in the same organization (S01b) | 409 Signing key with kid "..." already exists |
| Different key material under the same kid, into a project in a second organization under the same account (S01x) | 409, same message |
| The same private material under a fresh kid into the target, promoted (S01c) | import 201, promote 200 |
| Target JWKS lists its kid for that material (S01e) | 450882 ms after polling began (10 s step; polling began after S01d, not at the S01c promotion) |
| Source access token at the target’s PostgREST, after S01e (S01f) | 401 PGRST301 |
The same token at the target’s managed Auth /user (S01g) | 403 bad_jwt “unrecognized JWT kid … for algorithm ES256” |
A kid is unique beyond one project, at least while one project holds it in_use;
other key states were not run. S01x used different key material, so the check is on
the kid alone. Both organizations belong to one account, so whether the check spans
accounts is not measured. Identical material under a fresh kid does not make the
target accept the source’s tokens either. Managed Auth’s error names the cause: it
looks the key up by kid. PostgREST refused after the target’s JWKS was already
listing the target’s kid; that it also looks up by kid is inferred, since PGRST301
is equally consistent with a verifier cache that had not picked the new key up yet.
Parts 1-4 run on third-party-auth trust, which is separate from signing-key import:
cross-project-auth X02 measured a spoke’s PostgREST accepting a hub token once the
hub was registered via oidc_issuer_url. Session-carry did not probe it, and managed Auth /user
acceptance under it was not measured. Part 4 covers what that trust costs at
rotation.
A project holds at most 3 previously_used signing keys. On the 02:55 UTC run an
import on the source (S01a) was refused 422 “There already are 3 previously used
signing keys. To proceed first revoke at least 1.”; the harness now revokes the
oldest key before importing. Revoke the oldest asymmetric key, never the HS256
legacy secret, which signs the legacy anon and service_role API keys.
Separately, one ad-hoc signing-key create outside the harness answered 429; a create on the
same project about a minute later was 201. One occurrence; cause and window not
measured.
What a live session carries across
Section titled “What a live session carries across”S02 ran against what a hosted move produces: a session created at the source, the five tables copied (users, identities, sessions, refresh_tokens, mfa_factors), the sequence resynced, and the two projects signing with different ES256 kids.
| Probe (2026-09-25, 02:55 UTC run) | Result |
|---|---|
Held source access token at the target’s managed Auth /user (S02a) | 403 bad_jwt “unrecognized JWT kid” |
| The same token at the target’s PostgREST (S02b) | 401 PGRST301 |
| Held refresh token at the target, after the copy (S02c) | 200 |
iss before / after that refresh (S02d) | source / target |
The refreshed access token at the target’s managed Auth /user (S02e) | 200, same user id |
The access token a browser holds at cutover fails at the target until the client
refreshes; the refresh token is the piece of the session that crosses, through the
row copy. A client that refreshes only on its own schedule
would carry a refused access token for up to one access-token lifetime, 3600 s on
defaults as read on the hub in Part 3; session-carry did not read the source’s.
Forcing a refresh at cutover, or lowering the source’s JWT expiry beforehand, would
shorten that window. Both are inferred, not measured. An aal2 session also needs
auth.mfa_amr_claims in the copy, or that first refresh drops it to aal1
(Part 3, S04).
A refresh at the source after the copy forks the session
Section titled “A refresh at the source after the copy forks the session”Login at the source gives R1; the rows are copied, so the target holds R1 unrevoked; a refresh at the source then turns R1 into R2.
| Probe (2026-09-25, 02:55 UTC run) | Result |
|---|---|
| R2 at the target (S03a) | 400 refresh_token_not_found |
| R1 at the target (S03b) | 200 |
| R2 at the source (S03c) | 200, gives R3 |
| R1 re-presented at the source, 12 s after S03c (S03d) | 400 refresh_token_already_used |
| R3 at the source, after S03d (S03e) | 200 |
| The target lineage from S03b, after S03d (S03f) | 200 |
A client that refreshed at the source after the copy holds a token the target never
saw, and is signed out at cutover with refresh_token_not_found. A client still on
the pre-copy token refreshes at the target though the source already revoked it,
while the source lineage also continues: from then on one session has two live
lineages, one per project. The reuse at the source in S03d did not revoke the
session’s other tokens, since R3 still refreshed afterwards; that is one trial, and
the conditions under which GoTrue’s reuse detection revokes a whole session were not
explored. Stop writes at the source, sign-ins and refreshes included, and make the
copy the last step before cutover.
What was not run
Section titled “What was not run”storageKey pinning is a client-side setting and session-carry had no browser, so
it was not run. Only password users were copied; OAuth and OIDC identities were not.
The other auth tables (one_time_tokens, flow_state, mfa_challenges, the SSO and
SAML tables, audit_log_entries) were neither copied nor probed.
What to do about it
Section titled “What to do about it”A practice with no module id is a design choice rather than a result; what this does not settle lists what nothing here measures.
| Practice | Evidence | Module |
|---|---|---|
Poll GET /v1/projects/{ref}/health?services=auth&services=rest&services=db before the tenant’s first request, then still retry the first write. | 2 of 2 fresh projects passed the per-service poll and failed the first admin/users call with 500 "Database error checking email", succeeding about ten seconds later. | supabase-lab AGENTS.md, provisioning note (2026-08-03); platform-facts F02d |
| While the dedicated project only verifies the hub’s tokens (Part 3, stance one), point the client’s auth base URL at the hub and its REST URL at the discovered ref. | The auth and REST URLs may both point at the dedicated project only after the identity copy (stance three). A refresh presented to the trusting project answers 400 refresh_token_not_found. Storage and Realtime acceptance of hub tokens is not measured. | cross-project-auth X03 (2026-08-17); tenant-promotion P01 (2026-08-04) |
Retire the source identity on the hub with DELETE /auth/v1/admin/users/{id} after the client’s first refresh, before reaping rows. | Until the delete both projects issue for the tenant; after it the source refuses invalid_credentials and refresh_token_not_found while the target is unaffected. | P03 (2026-08-04) |
Create the standby key with POST /v1/projects/{ref}/config/auth/signing-keys and the body {algorithm}, using a PAT, one standby at a time. | The project’s /auth/v1/admin/signing-keys is a 404. A create while one is already present answers 422 "already has a signing key in standby". Status values are standby, in_use, previously_used and revoked, and kid equals id. | key-rotation 2026-08-25 live run (R01 premise refuted) |
| Budget the issuer’s publication at minutes and the consumer’s cache at the whole window, and hold new sign-ins through the window. | The hub published the new kid in 241 s on 2026-08-25 and in about 7 minutes on 2026-08-04, while the trusting project’s cached set did not change across 116 probes in 20 minutes or 282 probes in 37 minutes; new sessions break first. | key-rotation R02 (2026-08-04 bash; 2026-08-25 live) |
Do not DELETE and re-POST the integration to clear the cache. | Even after the hub’s JWKS shows the new kid: a fresh integration created while the hub demonstrably advertised the new key still served the original kid set. | key-rotation 2026-08-25 (R03 side-finding) |
| Randomise or namespace test emails when rehearsing against the same project pair. | adminCreate answers 422 on a duplicate address, so a rehearsal with constant addresses passes exactly once. | tenant-promotion RUNLOG, “Emails are randomised per run” |
Copy auth.mfa_amr_claims after auth.sessions, selected through auth.sessions. | Without it an aal2 session came back aal1 with no amr methods on its first target refresh; with it, aal2 and password+totp. The same token refreshed at the source stayed aal2. | session-carry S04 (S04a, S04b, S04c; 2026-09-25) |
| Do not plan a whole-app move on one signing-key kid shared by source and target. | The same kid on a second project answered 409 in the same organization and in another one; the same ES256 material under a fresh kid imported, but the target still refused source tokens (PostgREST 401 PGRST301, managed Auth 403 bad_jwt). | session-carry S01 (S01b, S01x, S01c, S01f, S01g; 2026-09-25) |
| Plan the cutover around one access-token lifetime, or force a client refresh at cutover. | Held source access tokens failed at the target (403 bad_jwt, 401 PGRST301); the held refresh token refreshed there (200) and iss moved to the target. The window length and the forced refresh are inference, not measured. | session-carry S02 (S02a-S02e; 2026-09-25) |
| Stop writes at the source, refreshes included, and make the copy the last step. | A refresh at the source after the copy left a token the target never saw (400 refresh_token_not_found); the pre-copy token R1 still refreshed at the target (200) while R2 refreshed at the source (200): two live lineages. | session-carry S03 (S03a, S03b, S03c; 2026-09-25) |
Before importing, revoke the oldest asymmetric previously_used key if 3 exist. | The next import answered 422 “There already are 3 previously used signing keys. To proceed first revoke at least 1.” Never revoke the HS256 legacy secret: it signs the legacy API keys. | session-carry S01 (S01a, 02:55 UTC run, 2026-09-25) |
Verification
Section titled “Verification”Run these against the same two-tenant fixture immediately after a promotion. Each check confirms that one property of the move survived: the token, the copied rows or the source project’s state. The negative controls, which check that access is denied, are in the build guide’s Verification table.
| Check | How | Expected |
|---|---|---|
| Token portable | same token against the dedicated project | accepted ([] before copy) |
| Promotion complete | after the copy, same token, dedicated project | the tenant’s rows |
| Promotion non-destructive | same token, shared project | rows still present until reaped |
| Vault survives | select decrypted_secret from vault.decrypted_secrets on the target | plaintext for secrets re-entered on the target; copied ciphertext fails with invalid ciphertext, and the root-key carry that would change that is not measured |
| Sequences reset | insert on the target | no PK collision |
Gotchas and lessons learned
Section titled “Gotchas and lessons learned”- Identical schemas on both tiers. Promotion becomes a data copy. Keep RLS enabled on dedicated projects even with one tenant - it costs nothing and means the two tiers never diverge.
- Vault does not survive the copy. The published rescue - carry the encryption
root key first, while the source still exists - is not measured: vault-root-key has
no runs, and its apply step has no known verb or path. The failure (
invalid ciphertexton copied ciphertext) is documented in the region migration guide; V02 would measure it and has not run. insert ... select *cannot work.auth.users.confirmed_atandauth.identities.emailareGENERATED ALWAYS, and Postgres rejects any non-DEFAULT value for them (428C9). Enumerating columns from the catalog is also what makes the copy tolerant of the two projects running different auth schema versions, which any pair of projects created months apart will.- The
bigserialsequence does not follow the rows. After landing a row withid = 1the sequence still reportedlast_value = 1, is_called = false, so the nextnextval()hands out an id that already exists. GoTrue rotates the refresh token on every use, so this fails on the tenant’s first refresh and looks like session porting being unsupported. It is a missingsetval. - A fresh project 500s for a while after it reports healthy.
500withSQLSTATE 57P01on the first call is Postgres restarting inside the settle window, not an answer. Retry before you believe a failure. Measured across five projects on 2026-08-04: create toACTIVE_HEALTHYtook 131-159 s, and all five then refused their firstadmin/userswrite with500 unexpected_failureand accepted the second, one poll later. It is not a race you can get lucky with - budget the retry. - Do not carry the source’s
auth.refresh_tokens.idinto a target that has already seen a login. Thesetvalin Part 3 assumes the target is pristine, which it is for a project provisioned for this promotion. Copy into one that has any prior auth activity and the insert dies onrefresh_tokens_pkeyinstead, because the low ids are taken. Either let the target assign the id (the token string is what the client presents, and thesetvalthen becomes belt-and-braces) or reconcile the ids yourself. Found on 2026-08-04 by running this copy twice against the same pair. - Re-creating the integration does not unstick a trusting project. There is no
PATCHon the third-party-auth endpoint, soDELETE+ re-POSTis the only lever - and the re-created integration reportedresolved: truewhile caching the OLD key again, because the hub’s own/auth/v1/.well-known/jwks.jsonwas still advertising only that key minutes after the rotation. Two lags are stacked: the issuer’s JWKS publication and the consumer’s cache. Forcing the consumer to re-resolve does not help while the issuer still publishes the old key set. On 2026-08-25 the consumer half was measured alone: a fresh integration created after the hub’s JWKS already advertised the new key still cached the original kid set, so re-creation fails even once the issuer has caught up.
What this does not settle
Section titled “What this does not settle”The architecture is only partly demonstrated. The routing layer is settled: on 2026-08-04 the discovery endpoint carried a promotion with no re-authentication and nothing in the data path, and ref-hiding turned out to be a vanity subdomain rather than a proxy (Part 2).
- The unexercised parts of the identity migration. MFA factors and retiring the source user were measured on 2026-08-04. OAuth and SAML identities were not, and need a real external provider.
- The untested parts of a whole-app move.
storageKeypinning, OAuth and OIDC identities, whether kid uniqueness spans accounts, the auth tables session-carry did not copy, and whether lowering the source’s JWT expiry before cutover shrinks the window in which held access tokens fail (Part 5). - The rotation anomaly. In one of two runs the old token started failing before its key was revoked, while that key was still the only one the trusting project had cached. A third run on 2026-08-04, capturing the JWKS body, the PostgREST error code and each key’s status at every probe, did not reproduce it: the old token returned 200 on all 140 of its probes. That makes the observation unstable rather than explained - one non-reproduction identifies no mechanism - so it stays here.
Evidence
Section titled “Evidence”| Claim | How it was checked | Result |
|---|---|---|
| A promoted tenant can refresh against its own project | Presented the hub’s refresh token to the dedicated project, and to the hub as control | Measured - no. 400 refresh_token_not_found at the dedicated project; the hub issues fine and the result is still accepted there |
| Verification needs the issuer to be alive | Deleted the hub, then kept reading at the dedicated project with a live token | Measured - no. HTTP 200 for the whole window, resolved_jwks still present, while refresh at the hub went 410 Gone |
| The cost of losing the hub | Read exp - iat and the hub’s jwt_exp | Measured - 3600 s, so one hour to lockout on defaults |
The coupling can be removed by copying auth.users | Copied one row to a project with NO integration configured, then logged in there | Measured - login succeeds, iss is the new project, the claim survives, RLS holds, and it refreshes its own tokens |
| A rotation on the hub propagates promptly | Rotated to a new ES256 key, then polled a new-key token against the trusting project for 20 minutes | Measured - no. 401 on 80/80 polls, with the integration’s resolved_at frozen pre-rotation (2026-08-03/04); 401 across 116 probes in 20 minutes on 2026-08-25, cached kid set unchanged |
| Revoking a key stops trusting projects accepting it | Marked the old key revoked, then polled its token for 15 minutes | Measured - no. 200 on 60/60 polls (2026-08-03/04). Not re-confirmed on 2026-08-25: the trusting project never cached the revoked key on that run |
| Re-creating the integration clears the stale cache | DELETE + re-POST, then polled at 500 ms | Measured - no. Cached the old kid again; the hub’s own JWKS was still advertising only that key (2026-08-04). On 2026-08-25 a fresh integration created after the hub’s JWKS advertised the new key still served the original kid set |
| The tenant’s live session can be ported | Copied users, identities, sessions and refresh_tokens in FK order, resynced the sequence, presented the OLD refresh token | Measured - accepted, new token issued by the new project. Zero re-logins |
| A discovery endpoint carries a promotion without a gateway | Client re-read its {ref, key} from a registry after the flip and continued with the refresh token it already held | Measured - zero password logins, rows read at the dedicated project, the other tenant unaffected, only the two project hosts contacted |
| Ref-hiding needs no proxy | check-availability then activate on the dedicated project | Measured - both 201, hostname carries no ref. Wants a bare label; the docs say CLI-only, the API accepts it |
| MFA survives promotion | Enrolled and verified a real TOTP factor, copied auth.mfa_factors, verified the same secret at the target | Measured - factor arrives verified, target session reaches aal2. Three runs |
An already-aal2 session stays aal2 through a target refresh | Stepped two users up to aal2 with TOTP at the source, one per variant: copied the five tables for the first (S04a) and the five plus auth.mfa_amr_claims for the second (S04b), refreshed each at the target; control S04c presented the S04a refresh token at the source | Measured, 2026-09-25 - S04a aal1 with no amr methods (amr_after none); S04b aal2, amr password+totp; S04c aal2 |
| One imported ES256 signing key can serve two projects | Imported one private JWK into the source, then the same kid into the target (same organization) and into a project in a second organization; then the same material under a fresh kid, and presented a source token to the target (S01) | Measured, 2026-09-25 - no. 409 for the shared kid (S01b, S01x); fresh kid imported 201, then target PostgREST 401 PGRST301 (S01f) and managed Auth 403 bad_jwt (S01g) after the target JWKS listed it (S01e, 450882 ms of polling) |
| A held access token works at the target after a whole-app copy | Copied five tables with the projects on different ES256 kids, presented the held access token, then the held refresh token, to the target (S02) | Measured, 2026-09-25 - no. Managed Auth 403 bad_jwt, PostgREST 401 PGRST301; the refresh token refreshed (200), iss moved source -> target, the new token 200 at /user |
| A refresh at the source after the copy is harmless | Refreshed at the source after the copy, then presented both the new and the pre-copy refresh token to both projects (S03) | Measured, 2026-09-25 - no. New token 400 refresh_token_not_found at the target; the pre-copy token 200 at the target while the source lineage also refreshed (200). Reuse at the source 12 s later 400 refresh_token_already_used, the token from S03c (R3) still 200 (one trial) |
| A project holds any number of old signing keys | Imported a key into a source already holding 3 previously_used keys (S01a, 02:55 UTC run) | Measured, 2026-09-25 - no. 422 “There already are 3 previously used signing keys” |
| Retiring the source identity closes both issuing paths | DELETE /auth/v1/admin/users/{id} on the hub, then password grant and old refresh token there, and a login at the target | Measured - invalid_credentials and refresh_token_not_found at the hub, target unaffected, the tenant’s rows still on the hub |
| The rotation window is the consumer’s cache, not the issuer’s | Re-read the hub’s JWKS and the trusting project’s cached set at every probe for 37 minutes | Measured - hub published the new kid in about 7 minutes; the consumer never re-resolved, 282 probes (2026-08-04); 241 s and 116 probes over 20 minutes on 2026-08-25 |
| A standby key is rate limited on creation | POST /v1/projects/{ref}/config/auth/signing-keys twice on a fresh hub, 2026-08-25 | Refuted - a create with a standby already present answered 422 "already has a signing key in standby" (R01b) and a create with none present answered 201 at once (R03c); the 2026-08-04 Please wait until <ISO8601> refusals (144 s, 127 s, 131 s) did not recur |
| Create to writable, not just healthy | Timed five projects from create to the first admin/users write that succeeded | Measured - 131-159 s to ACTIVE_HEALTHY; 5 of 5 refused the first write and took the second |
Copying auth.refresh_tokens ids into a used target | Ran the copy twice against the same project pair | Measured - refresh_tokens_pkey collision the second time; let the target assign the id |
| One token validates against two projects | Same unchanged token against both | Measured - accepted by both |
| Promotion works without re-login | Copied one tenant’s rows, re-read with the same token | Measured - rows returned, no re-auth |
| Promotion is non-destructive | Re-read the shared project after the copy | Measured - rows still present |
| Vault survives a transfer but not a copy | Cross-referenced from the transfer lab | Measured - decrypts after transfer, invalid ciphertext after pg_dump |
File reference
Section titled “File reference”The harness for these runs lives in the supabase-lab repo under
experiments/tenant-promotion/:
| File | What it holds |
|---|---|
tests/p01-promotion-follows.ts | the discovery-endpoint client following a promotion with the refresh token it already held |
tests/p02-mfa-survives.ts | the TOTP factor copied with auth.mfa_factors and verified at the target |
tests/p03-retire-source-identity.ts | retiring the source user: both issuing paths fail at the source, target unaffected |
tests/p04-vanity-subdomain.ts | the check-availability + activate calls and the bare-label rule |
lib/promote.ts | the auth table copy and the setval resync |
RUNLOG.md | the per-test narrative and what the first live run changed, pinned to the lab commit; the raw probes are not published because they carry project refs |
key-rotation/out/2026-08-25/ | the redacted artifacts of the 2026-08-25 rotation run Part 4 quotes (R01-R03) |
session-carry/RUNLOG.md | the 2026-09-25 whole-app move (S00-S04) that Part 5 and the aal2 paragraph in Part 3 quote |
session-carry/out/2026-09-25/ | the redacted artifacts of both 2026-09-25 session-carry runs |
session-carry/lib/carry.ts | copyUser: the per-table where-clauses, mfa_amr_claims keyed through auth.sessions |
Modules
Section titled “Modules”| Module | Experiment | Test | Artifact |
|---|---|---|---|
| C04 | tenant-consolidation | c04-key-collision.ts | none published |
| F02d | platform-facts | f02-project-surface.ts | none published |
| P01 | tenant-promotion | p01-promotion-follows.ts | none published |
| P03 | tenant-promotion | p03-retire-source-identity.ts | none published |
| R01 | key-rotation | r01-rate-limit.ts | out/2026-08-25 |
| R01b | key-rotation | r01-rate-limit.ts | out/2026-08-25 |
| R02 | key-rotation | r02-no-reresolve.ts | out/2026-08-25 |
| R03 | key-rotation | r03-revoked-key.ts | out/2026-08-25 |
| R03c | key-rotation | r03-revoked-key.ts | out/2026-08-25 |
| S00 | session-carry | s00-no-shared-key.ts | out/2026-09-25 |
| S01 | session-carry | s01-shared-key.ts | out/2026-09-25 |
| S02 | session-carry | s02-rows-keys-differ.ts | out/2026-09-25 |
| S03 | session-carry | s03-refresh-after-copy.ts | out/2026-09-25 |
| S04 | session-carry | s04-aal2-carry.ts | out/2026-09-25 |
| V02 | vault-root-key | v02-copy-without-key.ts | none published |
| V04 | vault-root-key | v04-deleted-source.ts | none published |
| X02 | cross-project-auth | x02-token-portability.ts | none published |
| X03 | cross-project-auth | x03-refresh-to-issuer.ts | none published |
Related
Section titled “Related”- Running many tenants on one Supabase project
- the shared tier this guide promotes a tenant off of: the hub, the RLS-scoped schema, and the trust wiring this runbook depends on.
- Migrating a Supabase project to another region
- the dump-and-restore mechanics behind the promotion copy, including the Vault root-key trap in full.
Linked from
- Consolidating one Supabase project per customer onto a shared project
- Migrate a Supabase project to another region, end to end
- Running a platform on the Supabase Management API
- Running many tenants on one Supabase project
- Supabase Auth end to end: tokens, trust, and what you can take in-house
- Where should a tenant live: one Supabase project per tenant, or many