Shared-instance tenancy on Supabase, and promoting a tenant out of it
If you give each of your end-users a backend - an app builder, a per-customer workspace, a low-code tool - the obvious move is one Supabase project per tenant. It isolates cleanly and the Management API provisions one in a call. It also commits you to roughly $10/month of compute for every tenant including the idle ones, and a project on a paid plan cannot be paused.
The alternative is to put the free and trial majority in one always-warm project, separated by row-level security on an identity claim, and give a tenant its own project only when it starts paying. This guide builds that, then moves a tenant out of it.
What you will build
Section titled “What you will build”- One hub project whose GoTrue issues every tenant’s token.
- One shared project holding many tenants, with RLS keyed on a
tenant_idclaim. - One dedicated project that a promoted tenant moves into.
- A promotion path that does not invalidate the tenant’s existing session.
Prerequisites: a Personal Access Token in SUPABASE_ACCESS_TOKEN, jq, and the
Supabase CLI if you want Edge Functions.
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. Claims marked measured in the
evidence table were executed and captured. Nothing here measures behaviour at
scale - two tenants say nothing about contention, and that limitation is carried
into the evidence table rather than dropped.
The third-party-auth claims were then re-run the same day on a second, disposable project pair created for that purpose: all three configuration shapes side by side, and portability under three trust states - unconfigured, trusted, and revoked - with the token held byte-identical throughout. Timings quoted for trust propagation come from that run, and their error bar is the poll interval, not a stopwatch.
Constants
Section titled “Constants”| Fact | Value |
|---|---|
| Third-party auth endpoint | POST /v1/projects/{ref}/config/auth/third-party-auth |
| Accepted config shapes | oidc_issuer_url, jwks_url, custom_jwks |
| Shapes that resolve | oidc_issuer_url and jwks_url, both on the create response - see the gotcha on custom_jwks |
Reported type | custom, for all three shapes - it does not tell you which one created an integration |
| A project’s public JWKS | https://<ref>.supabase.co/auth/v1/.well-known/jwks.json |
| Required token algorithm | asymmetric only; symmetric JWTs are rejected |
| Required token header | a kid identifying the key |
| Required token claim | role, valued authenticated for ordinary end users |
| JWKS refresh | cached in project config; measured: a trusting project did not accept new-key tokens within 20 minutes of a rotation, and honoured a revoked key for 15 - see Rotating the hub’s key |
The identity decision comes first
Section titled “The identity decision comes first”Every tenant’s token must stay valid when that tenant moves from the shared project to its own. Supabase verifies a token by signature against a JWKS it has been told to trust, so if the issuer does not change, the tenant’s physical placement can.
The third-party auth guides pair Supabase with an external provider - Clerk, Auth0,
Cognito, WorkOS. You do not need a third party, because Supabase Auth can itself act
as an OAuth 2.1 and OpenID Connect identity
provider: “allows other
applications and services to use your Supabase project as their authentication
provider”. Every project serves a full discovery document (verified: HTTP 200, with
issuer, jwks_uri, authorization_endpoint, token_endpoint, and the
authorization-code and refresh-token grants):
curl -s "https://<hub-ref>.supabase.co/auth/v1/.well-known/openid-configuration" | jqThe wiring below uses the issuer URL as oidc_issuer_url, because discovery resolves
jwks_uri itself and so survives a rotation that a hardcoded JWKS URL would not.
Both shapes were measured on a fresh project pair and both resolve on the create
response - in tens of milliseconds, to identical key material (same kid, same ES256
key). The choice is which URL you would rather hard-code, not a capability
difference. Rotation itself is still untested (see the caution below); what is now
settled is that the issuer form resolves at all, which is the part the third shape
fails at.
The mechanism underneath is simply that a Supabase project publishes an asymmetric JWKS:
curl -s "https://<hub-ref>.supabase.co/auth/v1/.well-known/jwks.json" | jqPoint every other project at that URL and they will all accept tokens the hub issued. That removes the external-IdP dependency from the architecture, at the cost of making the hub a hard dependency for every project trusting it.
Use a real external provider instead if you already have one, if you need identity to
outlive any single Supabase project, or if a fleet-wide rotation blast radius is
unacceptable. The wiring below is identical either way - only the jwks_url changes.
Part 1: wire the trust
Section titled “Part 1: wire the trust”Register the hub as an issuer on the shared project and on every dedicated project:
HUB=<hub-ref>for REF in <shared-ref> <dedicated-ref>; do curl -s -X POST \ -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \ -H 'Content-Type: application/json' \ -d "{\"oidc_issuer_url\":\"https://$HUB.supabase.co/auth/v1\"}" \ "https://api.supabase.com/v1/projects/$REF/config/auth/third-party-auth" \ | jq -c '{id, type, resolved: (.resolved_jwks != null), resolved_at}'doneSubstitute {"jwks_url":"https://$HUB.supabase.co/auth/v1/.well-known/jwks.json"} if
you prefer to pin the key endpoint; it resolves the same way.
resolved must be true and resolved_at must be populated. If either is null the
project will reject every token from that issuer, and the failure surfaces as
PGRST301 "No suitable key was found to decode the JWT" at request time rather than
at configuration time.
List and remove integrations with:
curl -s -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \ "https://api.supabase.com/v1/projects/$REF/config/auth/third-party-auth" \ | jq -c '[.[] | {id, oidc_issuer_url, jwks_url, resolved: (.resolved_jwks != null)}]'
curl -s -X DELETE -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \ "https://api.supabase.com/v1/projects/$REF/config/auth/third-party-auth/<tpa-id>"The DELETE is the de-provisioning control, and it bites quickly: tokens the
integration was verifying start failing with PGRST301 under a second after it is
removed (measured across three repeats). Neither direction is synchronous with the
API call - a probe fired immediately after either write still sees the old state - so
treat both as fast, not instant.
Part 2: put the tenant id somewhere a tenant cannot change
Section titled “Part 2: put the tenant id somewhere a tenant cannot change”Create each tenant’s user on the hub with tenant_id in app_metadata. That object
is writable only through the admin API, so a client cannot alter its own value - a
tenant cannot promote itself into another tenant’s rows by editing a claim.
curl -s -X POST "https://$HUB.supabase.co/auth/v1/admin/users" \ -H "Authorization: Bearer $HUB_SERVICE_KEY" \ -H "apikey: $HUB_SERVICE_KEY" \ -H 'Content-Type: application/json' \ -d '{"email":"user@tenant-a.example","password":"...","email_confirm":true, "app_metadata":{"tenant_id":"tenant-a"}}' \ | jq -c '{id, email, app_metadata}'The resulting access token is ES256, carries role: authenticated, and holds
tenant_id inside app_metadata.
Part 3: the shared schema
Section titled “Part 3: the shared schema”Every tenant-scoped table carries a tenant_id and a policy comparing it to the
claim:
create table public.items ( id bigserial primary key, tenant_id text not null, body text not null);
alter table public.items enable row level security;
create policy tenant_isolation on public.items using (tenant_id = (auth.jwt() -> 'app_metadata' ->> 'tenant_id')) with check (tenant_id = (auth.jwt() -> 'app_metadata' ->> 'tenant_id'));
grant usage on schema public to authenticated;grant select, insert, update, delete on public.items to authenticated;grant usage, select on sequence public.items_id_seq to authenticated;using governs reads and with check governs writes, but on a for all policy the
two are not independent: Postgres falls back to the using expression for new rows
when with check is absent, as CREATE POLICY documents.
Tested on Postgres 17.10 - with using
alone, an insert tagged for another tenant and an update reassigning an own row both
failed with new row violates row-level security policy. So writing both clauses here
is explicitness, not a hole being closed. Write them anyway: the moment the read and
write predicates need to differ, or the policy is split per command, the fallback stops
applying - and a for insert policy cannot use using at all, since Postgres rejects
it with only WITH CHECK expression allowed for INSERT.
Index the discriminator on every tenant-scoped table; every query filters on it:
create index on public.items (tenant_id);Part 4: prove the isolation before you rely on it
Section titled “Part 4: prove the isolation before you rely on it”Read as tenant A, then as tenant B, against the shared project’s Data API:
curl -s "https://<shared-ref>.supabase.co/rest/v1/items?select=tenant_id,body&order=id" \ -H "apikey: $SHARED_ANON" -H "Authorization: Bearer $TOKEN_A" | jq -c# [{"tenant_id":"tenant-a","body":"a-row-1"},{"tenant_id":"tenant-a","body":"a-row-2"}]Then the attempt that matters - tenant B asking for tenant A’s rows by name:
curl -s "https://<shared-ref>.supabase.co/rest/v1/items?select=tenant_id,body&tenant_id=eq.tenant-a" \ -H "apikey: $SHARED_ANON" -H "Authorization: Bearer $TOKEN_B" | jq -c# []An empty array, not an error. RLS filters rather than refuses, which is what you want
- there is no oracle telling B that A exists.
Part 5: promote a tenant to its own project
Section titled “Part 5: promote a 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 (Part 1). 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));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, no token reissue, no password reset. 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';How clients learn where a tenant lives
Section titled “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. A config lookup, not a proxy.
- 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 hardest thing a gateway would otherwise have to do - it never needs to reissue or rewrite tokens. Neither approach was built or tested in this run. They are the part of this architecture that remains design.
Verification
Section titled “Verification”Run these against a two-tenant fixture before trusting the setup. The negative controls matter more than the positive ones - without them, “the token worked” is indistinguishable from the API not checking anything.
| Check | How | Expected |
|---|---|---|
| JWKS resolved | GET /v1/projects/{ref}/config/auth/third-party-auth | resolved_jwks non-null, resolved_at set |
| Token accepted | tenant A’s token against the shared Data API | rows, not PGRST301 |
| Tenant sees only itself | read items as A, then as B | each gets only its own rows |
| Cross-tenant read denied | as B, ?tenant_id=eq.tenant-a | [] |
| Wrong signature rejected | a token signed by an untrusted key | PGRST301 |
| Untrusted issuer refused | the tenant’s real token against a project with NO integration configured, before wiring it | PGRST301 |
| Claim not user-writable | as A, PUT /auth/v1/user with app_metadata.tenant_id = tenant-b | 403 Updating app_metadata requires admin privileges |
| Signup cannot self-assign | POST /auth/v1/signup with app_metadata in the body | user created, app_metadata has no tenant_id, read returns [] |
| Control: admin CAN move it | PUT /auth/v1/admin/users/{id} with the secret key | token carries the new tenant_id and the read follows it |
| Anon is powerless | anon key as bearer | [] - grants are authenticated-only |
| Write scope enforced | as B, insert a row with tenant_id = 'tenant-a' | rejected by with check |
| 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 |
| Trust removal takes effect | DELETE the integration, re-read with the same token | PGRST301 again, within a second |
| Vault survives | select decrypted_secret from vault.decrypted_secrets on the target | plaintext, no invalid ciphertext |
| Sequences reset | insert on the target | no PK collision |
Gotchas and lessons learned
Section titled “Gotchas and lessons learned”custom_jwksis accepted and never works. The endpoint takes it, returns 201, and echoes your key material back intact -kty,crv,alg,use,kid,xandyall present on a subsequentGET. Butresolved_atstays null and PostgREST never picks the keys up. A valid ES256 token was rejected withPGRST301continuously for 7 minutes 32 seconds across 31 polls. Substitutingjwks_urlon the same integration resolved instantly and the equivalent token was accepted on the first request. Re-measured on a fresh project pair: still 201, still null at 92 seconds, so this is the shape’s behaviour and not one project having a bad day. Useoidc_issuer_urlorjwks_url. The failure mode is bad because everything looks configured.- The
typefield iscustomwhichever shape you sent. It is not a discriminator. To know how an integration was wired, readoidc_issuer_url,jwks_urlandcustom_jwksthemselves - and checkresolved_atwhile you are there, because that is the field that distinguishes a working integration from a decorative one. - Order your portability test so acceptance is attributable. Probe the target with the tenant’s real token BEFORE configuring trust, and require a refusal. An anon-key control does not substitute for this: the anon key is signed by a key the target already trusts, so it is stopped by grants and RLS and says nothing about signature validation. Hold the token constant and vary only the target’s configuration.
PGRST301means the key, not the claim. “No suitable key was found to decode the JWT” is a signature-verification failure - unresolved JWKS, wrongkid, or a symmetric token. A token that verifies but lacks the right claims fails differently: you get[]from RLS, or a permission error from grants. ReadingPGRST301as an RLS problem sends you to debug policies that are fine.tenant_idbelongs inapp_metadata.user_metadatais client-writable.with checkon afor allpolicy is explicitness, not protection. Omitting it does not open a write hole - Postgres reusesusingfor new rows, verified on 17.10. The claim that a tenant could then write rows it cannot read is wrong, and it was in this guide until it got tested. Write both clauses so the write predicate survives a later narrowing ofusing.- 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 unless you move the encryption root key first, and the source must still exist when you do.
- The hub is a dependency you are choosing. Every project trusting its JWKS depends on it being reachable and on its signing key. That is the price of not running an external IdP.
- There is a second scoping dimension if you enable the OAuth server. Access
tokens issued through it carry a
client_idclaim alongside the usual ones, and RLS can read it withauth.jwt() ->> 'client_id'to give different permissions to different client applications. Useful when tenants have their own apps rather than just users. Not tested here.
Promotion moves the data, not the identity
Section titled “Promotion moves the data, not the identity”A promoted tenant keeps its session, and that is the point of the trust wiring. It does not become independent, and the difference is measurable.
| 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, not an issuer of its own tokens. 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, which was not the expectation going in.
Migrating the identity, so the dedicated project issues its own tokens
Section titled “Migrating the identity, so the dedicated project issues its own tokens”Unsupported: nothing in Supabase’s docs sanctions writing into the auth schema.
Measured on throwaway projects, and smaller 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, not federation; the
trust wiring is what carries a tenant until it is promoted, not after.
Copying four makes it seamless. 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.-- `select *` does NOT work - see the generated-column trap below.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);Three traps, each of which broke a 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 detonates on the tenant’s first refresh and looks like session porting being unsupported. It is not; 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.
Not exercised, and worth knowing before running this against anything real: MFA factors, OAuth and SAML identities (this was email and password only), and retiring the source user on the hub afterwards.
Rotating the hub’s key is a maintenance window
Section titled “Rotating the hub’s key is a maintenance window”Every project that trusts the hub caches the hub’s key set. That cache is the whole story, and it makes rotation slow in both directions.
| Step | Measured |
|---|---|
| Create a standby signing key | rate limited on a fresh project - Please wait until <ISO8601>, 144 s and 127 s across two runs |
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 |
| Old token after rotation | still accepted |
Old token after that key is revoked | still accepted for 15 minutes - 200 on all 60 polls |
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.
Two consequences, and the second is the one that matters.
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. That is the opposite of the usual rollout shape, where new code paths fail and old ones keep working - here the fresh credential is the broken one.
Revocation does not revoke. Supabase’s signing-keys documentation says asymmetric revocation “is automatic via the key discovery endpoint”, which is true of the issuing project and false of a third-party consumer inside its cache window. During an incident the hub can rotate and revoke in seconds, and for the next half hour every trusting project will accept the compromised key and reject its replacement.
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.
What this does not settle
Section titled “What this does not settle”Stated plainly, because the architecture is only partly demonstrated.
- Scale. Two tenants and three rows. Nothing here supports a claim about contention, per-tenant throughput, or how far one shared instance stretches.
- Noisy neighbours. The shared tier has no per-tenant resource limit. One tenant can consume the instance’s CPU, connections or IO. There is no measurement of how bad that gets.
- Per-tenant backup and PITR. The shared instance has one backup schedule. Restoring one tenant to a point in time means restoring a copy and filtering, which is not the same product as per-project PITR.
- The routing layer. Neither discovery endpoint nor gateway was built.
- The unexercised parts of the identity migration. The copy is measured for email and password. MFA factors, OAuth and SAML identities, and retiring the source user on the hub are not.
- 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. The other run behaved as expected. Only status codes were captured, so no mechanism is offered; settling it needs the JWKS body per step and the PostgREST error code rather than the HTTP status.
- The
roleclaim specifically. Only tested with an anon bearer, which fails on grants rather than on the claim. A clean test needs the hub to mint a token with a non-authenticatedrole via a custom access token hook.
Evidence
Section titled “Evidence”| Claim | How it was checked | Result |
|---|---|---|
| A project’s own JWKS can be trusted by another project | Registered the hub’s /auth/v1/.well-known/jwks.json as jwks_url on a second project | Measured - resolved immediately |
| A project serves an OIDC discovery document | GET /auth/v1/.well-known/openid-configuration on a live project | Measured - 200, full document |
oidc_issuer_url resolves, like jwks_url | Created both shapes side by side on a fresh project pair and polled for resolution | Measured - both resolved on the create response, to identical key material; oidc_issuer_url in 59ms and 249ms across two runs |
The reported type identifies the shape | Compared the type field across all three shapes | Measured - custom for all three; it does not |
| An untrusted issuer’s token is refused | Same tenant token against the target with no integration configured | Measured - 401 PGRST301 |
| Deleting the integration stops the token | DELETE, then re-read with the same token, polling | Measured - PGRST301 after 587-654ms across three repeats |
| Trust changes are synchronous with the API call | Probed immediately after create and after delete | Measured - no; the immediate probe sees the old state both ways. Accept landed in 1038-1169ms |
| 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 |
| 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 |
| 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 |
| 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 |
| Scale-to-zero on Nano removes the premise for this architecture | Platform documentation; access is granted per account | Documented, not tested |
OAuth tokens carry a client_id claim usable in RLS | Platform documentation | Documented, not tested |
custom_jwks never resolves | Created it, polled the Data API with a valid ES256 token | Measured - PGRST301 for 7m32s over 31 polls; jwks_url worked first try |
| RLS on an external claim isolates tenants | Read items as two different tenants | Measured - each saw only its own rows |
| Cross-tenant read denied | Filtered explicitly for the other tenant’s id | Measured - [] |
| A wrong-key token is rejected | Signed with a key the target does not trust | Measured - PGRST301 |
| 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 |
role must be authenticated | Not isolated - only an anon bearer was tried, which fails on grants | Documented, not tested |
| Behaviour at scale | Two tenants, three rows | Not tested |
| Per-tenant isolation, PITR, noisy-neighbour protection on the shared tier | Structural: one instance, row-level separation | Not achievable on this tier by construction |
Related
Section titled “Related”- Consolidating Supabase accounts into one organization
- if a project per tenant is the right shape after all, this moves them under one organization without a migration.
- Migrating a Supabase project to another region
- the dump-and-restore mechanics behind the promotion copy, including the Vault root-key trap in full.