Skip to content

Where should a tenant live: one Supabase project per tenant, or many

If you are building a platform that gives each of your end-users a backend - an app builder, a low-code tool, a per-customer workspace - a tenant has to live somewhere: its own Supabase project, a shared project holding many tenants, or, at sufficient scale, Supabase for Platforms’ managed tier. One project per tenant is clean, it isolates tenants, and the Management API makes it a single call - but it also quietly commits you to a linear compute floor: every tenant is a dedicated Postgres instance you pay for whether or not anyone is using it, and on a paid plan you cannot pause a project. This doc works out what each shape costs, tests whether the cheaper one actually isolates tenants, and ends with the decision itself. The implementation of each shape is its own guide: running many tenants on one project, promoting a tenant to its own project, consolidating tenants onto a shared project, and moving projects under one organization.

Everything with a number attached is either from the public Supabase pricing page1 or measured live against a real project (created and destroyed for this doc); the isolation model is proven with a runnable test you can re-run yourself.

TL;DR:

  • The floor is per project, not per user. A paid project cannot be paused, so every tenant that owns one costs about $10/mo of compute whether or not anyone signs in. A thousand idle tenants is $10k/mo before a single query.
  • Give the idle majority rows instead of instances. One always-warm project, RLS keyed on a tenant_id claim from the JWT, marginal cost per tenant ~$0. Adding a tenant is two API calls instead of 131-159 s of provisioning.
  • The isolation is real and it is forge-resistant. Tested three ways: the mechanism on Postgres, end-to-end through the live Data API, and against a signed-in user actively trying to reassign its own tenant_id. It fails closed on a missing claim.
  • Promotion to a dedicated project costs zero re-logins, MFA included - the session ports if you copy the auth rows in FK order. The session copies across; the source stays live until retired - retiring it is one DELETE.
  • You do not need a gateway. Placement is a lookup the client does at runtime and ref-hiding is a project setting, so nothing has to sit in the request path. That was measured, and it removed a component from this design.
  • The costs you are accepting: you own the isolation model, rotating the issuer’s signing key is a maintenance window rather than an operation, and nothing here is measured at scale.

On a paid plan, projects do not pause:

  • “Projects under a paid plan cannot be paused and are not subject to automatic pausing for inactivity. To pause a project currently under a paid plan, first transfer the project to an organization on the Free plan.”2
  • The pricing-page feature matrix lists Pausing as Free “after 1 week of inactivity”, paid plans “Never”.

So a per-tenant-project platform pays for every tenant’s compute continuously. There is no idle switch.

Billing is per organization for the plan, and per project, per hour for compute. From the pricing page:

ItemPriceNote
Pro plan$25/moorganization-level, flat; includes $10/mo compute credit
Compute credit$10/mocovers exactly one Micro instance
Micro compute$10/mo ($0.01344/hr)2-core ARM, 1 GB RAM, 60 direct / 200 pooler conns

I confirmed the Micro figure straight from the live billing API on a freshly created project (GET /v1/projects/{ref}/billing/addons):

{ "id": "ci_micro", "name": "Micro",
"meta": { "cpu_cores": 2, "memory_gb": 1, "connections_direct": 60, "connections_pooler": 200 },
"price": { "description": "$0.01344/hour (~$10/month)", "amount": 0.01344, "interval": "hourly" } }

So beyond the first (credit-covered) project, each always-on tenant is about $10/mo of compute doing nothing while idle. Ten idle tenants is $100/mo; a thousand is $10k/mo - a floor that scales with tenant count, not usage. That linear floor is the whole problem, and it exists only because we assumed tenant == project.

The reframe: decouple the tenant from the project

Section titled “The reframe: decouple the tenant from the project”

The floor is fixed only if a tenant must be a physical instance. Break that:

Logical tenant (stable tenant_id, stable endpoint, stable auth issuer) is not the same thing as a physical backend (a project, a row-range in a shared project, or a snapshot). A registry maps tenant_id -> placement.

Once decoupled, “where does an idle tenant live” is a placement policy. The cheap answer for small, intermittently-used tenants: they do not get their own instance at all - they share one.

Tenant clientsExternal IdPauthenticateDiscovery endpoint- tenant_id -> {ref, publishable key}- read at runtime, not in the request pathresolve placementTier S - shared multi-tenantONE always-warm projectmany tenants, RLS by tenant_idmarginal cost per tenant ~= 0free / idle majority: directTier D - dedicated projectsone project per paying tenantfull isolation + portability~10/mo eachpaying tenants: directJWT (role + tenant_id claim)Control plane- registry: tenant_id -> {tier, placement}- drives Management API- promotion jobsreads registryprovision / seedprovision / migratepromote on conversion

Which to pick, before the details. The three shapes on the rows that decide it; the full table follows later in this doc:

One project per tenantShared tier (this doc)SfP scale-to-zero
Idle-tenant compute$10/mo each (linear)~$0 marginal$0 while asleep
Add a tenantprovision (131-159 s)instantprovision
Isolationhard (separate instance)logical (RLS)hard
Availabilityanyoneanyonegated

Tier S - shared multi-tenant. The free / idle / trial majority live together in one always-warm project, isolated at the row level by RLS on a tenant_id claim. Their marginal compute cost is ~$0 because the instance is sized to aggregate load and amortised across everyone. No pausing problem, because nothing is idle - the shared instance is always warm and always cheap per tenant.

Tier D - dedicated project. When a tenant converts or grows, it graduates to its own project. Now the $10/mo is justified by revenue, and it gets hard isolation, its own backups, and portability.

Placement discovery, not a gateway. An earlier version of this architecture put a gateway in the request path to do two jobs: hide the project ref and inject the right key. Both were measured on 2026-08-04 and neither needs a component in front of the data plane.

Placement is a lookup the client does at runtime: it asks a discovery endpoint for its current {ref, publishable key} and then talks to that project directly. A tenant moved from the shared project to a dedicated one kept working across the move with zero password logins - the refresh token the client already held minted a session at the new project, and the only hosts it contacted were the two projects themselves. Ref-hiding is a project setting: a vanity subdomain gives the tenant a hostname that contains no project ref. So the gateway leaves the architecture, and with it the availability and latency cost of terminating every tenant request.

External IdP. Use one external issuer (Clerk, Auth0, WorkOS, or your own) as the stable JWT issuer for every tenant. Each project - shared or dedicated - is configured as third-party auth5 trusting that one JWKS. RLS reads the token’s claims; Supabase only needs a valid signature and a role claim, so a tenant can move S -> D (its physical ref changes) without logging anyone out, because the issuer never changed.

Promotion is the only migration you build, and it runs once per tenant at the good event (a conversion), not on every cold start: provision a dedicated project, copy that tenant’s slice out of the shared instance (logical replication or dump-restore of its rows and roles), repoint the registry, reap the old rows. Storage is a separate pass: objects are not rows, so a per-tenant copy has to download and re-upload through the Storage API rather than fall out of the same extract.

Two things about that copy are measured rather than assumed. MFA travels: a TOTP factor enrolled on the shared project arrives at the destination already verified, and the same secret produces a code that verifies there for an aal2 session6 - without which “zero re-logins” would quietly exclude every account that had enrolled one. Retiring the source identity is a single DELETE /auth/v1/admin/users/{id} and it closes both issuing paths at the source while the destination carries on; the tenant’s ROWS stay behind, so reaping them is a separate deliberate step. Moving users between projects below covers the sequence and claim-placement primitives that this copy and the opposite direction - consolidation - both depend on.

Promotion moves a tenant out of the shared project; consolidation moves a project’s users onto it. Both carry auth.users rows between two projects, and both have to keep each user’s uuid so the foreign keys pointing at it still resolve. They use opposite mechanisms, because they are preserving different things.

PromotionConsolidation
What has to survivethe live sessionthe credential
Mechanisminsert into auth.users ... select from a JSON payload, plus auth.identities, auth.sessions and auth.refresh_tokensPOST /auth/v1/admin/users carrying id, password_hash and app_metadata
What it costswrites into the auth schema, which is unsupportedthe user signs in again, with the same password
The hard partauth.users.confirmed_at is generated, so select * cannot work, and auth.refresh_tokens.id is a bigserial the destination must assign itselftwo independently provisioned projects collide, and the email index is over the raw column so a copy can land a second row for one person

The admin API is the supported path and the one to reach for by default, but it cannot carry a session: it mints a user, not the refresh token that user is holding. That is the whole reason promotion drops into SQL, and the reason merging is not promotion run backwards.

Three constraints hold either way. Keep the uuid, or every row already referencing that user is orphaned. Put tenant_id in app_metadata rather than user_metadata, because only the admin API can write it and that is what stops a tenant editing its own claim - which also means setting it at creation rather than after. And resync any sequence behind a copied bigserial: the sequence does not travel with the rows, so the next nextval() returns an id that was just imported. setval past the highest migrated id before anyone writes.

Promoting a tenant to its own project and consolidating a project per customer onto a shared one each carry the runbook for their direction.

Tested: does shared-instance RLS actually isolate tenants?

Section titled “Tested: does shared-instance RLS actually isolate tenants?”

The entire cost argument rests on one claim - many tenants in one Postgres, isolated by RLS on a JWT claim, cannot see or touch each other. That is testable, so I tested it twice: the mechanism in a throwaway Postgres, then end-to-end through a real Supabase project’s Data API.

One shared table, RLS keyed on the tenant_id claim, one policy covering reads and writes:

create table items (
id bigint generated always as identity primary key,
tenant_id uuid not null,
body text not null
);
alter table items enable row level security;
grant select, insert, update, delete on items to authenticated;
-- tenant_id comes from the verified JWT, never from client input
create function jwt_tenant() returns uuid language sql stable as $$
select nullif(auth.jwt() -> 'app_metadata' ->> 'tenant_id','')::uuid
$$;
create policy tenant_isolation on items
using (tenant_id = jwt_tenant()) -- reads
with check (tenant_id = jwt_tenant()); -- writes

The using clause filters what a tenant can read; with check stops a tenant writing a row tagged as someone else - though on a for all policy Postgres reuses using for new rows when with check is omitted, so the second clause is explicitness rather than the thing holding writes shut (verified on Postgres 17.10). stable + null-safe parsing means a missing claim yields null and matches nothing - it fails closed.

Setting the role and request.jwt.claims GUC exactly as PostgREST does, inside a transaction per tenant context:

TestExpectationResult
Tenant A readsonly A’s rowspass
Tenant B reads (same table)only B’s rowspass
A inserts a row tagged Bblocked by with checkpass (RLS violation)
A reassigns its row to Bblocked by with checkpass (RLS violation)
Unknown-tenant claim readsnothingpass (0 rows)
Missing claim readsnothing (fail-closed)pass (0 rows)
service_role (BYPASSRLS) readsall tenants (admin plane)pass

Proof 2 - end-to-end, live Supabase Data API

Section titled “Proof 2 - end-to-end, live Supabase Data API”

The stronger test: a real project, real users created through GoTrue with tenant_id in app_metadata, real issued JWTs, and requests through the actual PostgREST endpoint (https://<ref>.supabase.co/rest/v1/items). The token a user receives carries exactly what the policy reads:

{ "role": "authenticated",
"app_metadata": { "provider": "email", "tenant_id": "1111...-1111" } }

Results, with real HTTP status codes returned by the Data API:

#Action (as real signed-in user)Result
1User A POST a tenant-A note201 Created
2User B POST a tenant-B note201 Created
3User A POST a row tagged tenant B403 - new row violates row-level security policy
4User A GET /items200 - only A’s row
5User B GET /items200 - only B’s row
6Anon GET /items (no user)200 - []

Two users, one physical project, complete isolation - enforced by Postgres, not by application code. That is the shared tier working through the real stack.

Proof 3 - a tenant cannot forge its own tenant_id

Section titled “Proof 3 - a tenant cannot forge its own tenant_id”

The isolation is only as good as the claim it keys on. tenant_id lives in app_metadata (admin-only) rather than user_metadata (client-writable) - so I tested whether a signed-in user can escalate. Tenant A, with a real token, tried to reach a seeded tenant-B secret:

Attack (as authenticated user A)Result
PUT /auth/v1/user setting own app_metadata.tenant_id = Bignored; fresh token still shows app_metadata.tenant_id = A
GET /items with the post-attack token200 - []; the seeded tenant-B secret stays invisible
Public signup smuggling app_metadata.tenant_id = Bnot applied

Had the policy keyed on user_metadata (which the client can set via PUT /auth/v1/user), a user could assign themselves any tenant_id. Keying on app_metadata, with the control plane as its only writer, is what the three attack paths above test: none of them moved the claim.

A dedicated tenant costs 131-159 s of provisioning and a standing $10/mo compute floor for as long as it exists, whether or not anyone signs in. A shared tenant costs two instant API calls - create the user, insert the row - and no marginal compute. The operation-cost reference has the method and the full provisioning table this was measured with - create to ACTIVE_HEALTHY, the first-write retry, and delete - against the same live project this section drew from.

One project per tenantShared tier (this doc)SfP scale-to-zero
Idle-tenant compute$10/mo each (linear)~$0 marginal$0 while asleep
Add a tenantprovision (131-159 s)instantprovision
Isolationhard (separate instance)logical (RLS)hard
Wake latencyn/a (always on)none (always warm)cold start
Per-tenant backup / portabilitynativeneeds work (filtered dump)native
White-label / own billingyours to buildyours to buildbuilt in
Availabilityanyoneanyonegated
Operational burdenmediumyou own the isolation modelmanaged

The shared tier is ~$0 marginal per idle tenant against $10/mo each, and adds a tenant instantly against 131-159 s of provisioning; it matches on always-warm responsiveness, and it is weaker on isolation strength and per-tenant portability - which is why you keep Tier D for tenants that have earned (and are paying for) those properties.

The SfP column is the only one with scale-to-zero, and it is Nano-only: measured 2026-08-17 on a normal paid organization, Nano is rejected at project creation (400 Minimum instance size on paid plans is Micro), at the addon mutation (400 addon_variant: Invalid input), and is absent from the project’s available_addons entitlement list entirely - the floor on a paid organization is really Micro, always-on. One more edge: a project created while an organization was on the free plan keeps its paused state after the upgrade, but once restored it cannot be re-paused (400 Project is not free-tier) - pause eligibility follows the organization’s current plan, not the project’s lineage. On a platform (SfP) organization, measured 2026-08-24, the create default is Nano - the SfP-prescribed create (no desired_instance_size) provisions a nano project (infra_compute_size: "nano", shared_buffers 224MB vs 256MB on the paid Micro default) and it can pause, which is the scale-to-zero economics the SfP column promises. Nano is still not a select or resize target on any plan - it is the platform create default only.

If your product gives its users a backend (an AI app builder, a white-label SaaS), there are two integration shapes, and they differ on who owns the Supabase organization and the billing relationship.

Path A - you provision on their behalf. Your platform owns a Supabase organization and stamps out one project per tenant through the Management API. Users never see Supabase. This is the shape the rest of this doc is about (and what SfP formalises, with scale-to-zero economics behind a contract). The embedded dashboard (Platform Kit) works here because it needs a Management API token server-side - which you have, because the projects are yours.7

Path B - bring your own backend. Your user connects THEIR OWN Supabase organization to your app via the Management API OAuth2 flow: register an OAuth app, the user approves, you get an access/refresh token pair that acts on their organizations and projects, and they keep the direct billing relationship with Supabase.8 The management surface is the same API; the credential is scoped to what the user approved. Platform Kit does not apply - it wants a PAT, and the user’s PAT is not yours to hold.

Measured on the OAuth surface (2026-08-17, normal paid organization):

  • an unknown client_id at /v1/oauth/authorize answers 422 {"message":"Unrecognized client_id"} - client validation fires before session validation, so there is no redirect to probe until a real client_id exists.
  • POST /v1/oauth/authorize/project-claim - the claim flow that hands a platform-provisioned project to the end user’s own organization - answers 404 {"message":"Cannot POST /v1/oauth/authorize/project-claim"}: the route does not exist for a normal organization’s credential class, it is not merely forbidden.
  • the jwt-bearer token grant validates parameters before any gating (422 Required parameter: client_id).

Pick Path A when your users are less technical and you want the billing relationship (and its margin) - the white-label row in the table above. Pick Path B when your users already have Supabase projects and want to keep them, at the cost of a narrower integration and no embedded kit. Offering both is legitimate; the claim flow is the contract-gated bridge from A to B when a tenant outgrows you. Any revenue share on usage is a commercial conversation with Supabase.

“White-label / own billing: yours to build” has a metering half: the invoice already itemises usage per project ref, an hourly poller over public endpoints estimates the month before it closes (exact ground truth, analytics exact at ~1 min lag, and one honest gap - no per-service egress bytes in the public per-project API), and your own gateway meters in real time. The full build - including the non-tenant case, one consolidated organization wanting per-project chargeback - is its own guide: Per-project cost attribution.

  • Is per-tenant physical isolation / portability a genuine product requirement? If not, run the shared tier for the free/idle majority and promote to a dedicated project on conversion. This removes the linear idle floor for the segment that causes it, and keeps hard isolation for the segment that pays for it.
  • If it is a hard requirement, it becomes a scale question: at low scale, keep one project per tenant (or let tenants bring their own via OAuth); at high scale, native SfP scale-to-zero is the managed answer - talk to Supabase.

The shared+promote architecture is cheaper because it stops paying for isolation that idle free tenants do not need, and Proofs 1-3 above test that end-to-end rather than assume it.

Where should a tenant live?Is per-tenant PHYSICAL isolationor portability a product requirement?Shared tier, promote on conversionidle majority on one warm projectRLS on a tenant_id claimdedicated project at the conversion eventno - the common caseHow many tenantsneed their own project?yesOne project per tenantthe simplest thing that works~10/mo per idle tenant, linearfew - the floor isthe price of the requirementDo you haveSfP access?manyno - the shared tierabove, this docSfP scale-to-zeromanaged, smallest compute tier onlygated - talk to Supabaseyes

A hard isolation requirement at low tenant count does not lead here at all - pay the floor, it is what the requirement costs. And the path where you want scale-to-zero and cannot get it lands back on the shared tier, because the alternative is paying a linear floor for tenants who are asleep.

The measured rows in the table below imply a short list of practices. Each names the module or run it rests on; a practice with no module id is a design choice rather than a result, and the design-only list holds what nothing here measures.

PracticeEvidenceModule
Retry the first write on a fresh dedicated project with backoff, after polling health per service.Poll GET /v1/projects/{ref}/health?services=auth&services=rest&services=db. 5 of 5 projects reported ACTIVE_HEALTHY, refused the first POST /auth/v1/admin/users with 500 unexpected_failure and accepted the second one poll later (2026-08-04); 2 of 2 passed the per-service poll and still failed the first write with 500 "Database error checking email", succeeding about ten seconds later (2026-08-03).this reference’s Verified row ‘Create -> healthy, and healthy is not writable’ (2026-08-04, n=5, bash run not in the lab repo); supabase-lab AGENTS.md, provisioning note (2026-08-03)
Keep the service_role and sb_secret_ keys off every tenant-reachable surface.Proof 1 row 7 reads every tenant’s rows with service_role (BYPASSRLS); sb_secret_ carries the same role, so treat both as a full cross-tenant read.Proof 1 (2026-07-09, Postgres 17.6)
Give control-plane workers a key minted with secret_jwt_template instead of service_role.Mint it on POST /v1/projects/{ref}/api-keys and read it with ?reveal=true (the create response redacts api_key otherwise). The templated role and custom claim reach auth.jwt() on the data plane on Pro and platform orgs.sfp-platforms S14 (2026-08-25, both org classes)
Test the write half of every policy with Prefer: return=minimal and count rows server-side.Proof 2 row 3’s 403 under the default return=representation cannot distinguish a with check refusal from a permitted write whose RETURNING the SELECT policy filtered: the identical insert landed 1 row under return=minimal and reported 403 42501 under the default.tenant-consolidation C05 (2026-08-04)
Create tenants only through POST /auth/v1/admin/users.POST /auth/v1/signup returns success and drops app_metadata without an error, so a control plane that uses signup ships tenants with no claim and their reads return [].Proof 3 row 3; re-measured 2026-08-03 on two fresh projects
Wire the trust with oidc_issuer_url or jwks_url, never custom_jwks.The third shape returns 201 and echoes the key material back.cross-project-auth X01 (2026-08-03)
Confirm resolved_at is populated before routing a tenant to the project.The custom_jwks shape never resolves: resolved_at was null past 92 s on a fresh pair.cross-project-auth X01 (2026-08-03)
Pick one of three stances before the first promotion.Without one, a hub outage locks every tenant out within one access-token lifetime (3600 s), because a refresh presented to a trusting project answers 400 refresh_token_not_found.cross-project-auth X03 (2026-08-17); this reference’s Verified row ‘A promoted tenant is independent by default’ (2026-08-03)
Put the hub on the critical path, use an external IdP, or copy auth.users at promotion.The first means giving the hub that availability; the third means the dedicated project mints its own tokens.cross-project-auth X03 (2026-08-17); this reference’s Verified row ‘A promoted tenant is independent by default’ (2026-08-03)
Schedule a hub signing-key rotation as a maintenance window with new sign-ins held.Manage the keys at /v1/projects/{ref}/config/auth/signing-keys, one standby key at a time (422 "already has a signing key in standby"). The trusting project’s cached kid set did not change across 116 probes in 20 minutes (2026-08-25) or 282 probes in 37 minutes (2026-08-04), and a fresh integration created while the hub’s JWKS was current still served the stale set. New sessions break first.key-rotation R02 (2026-08-04 bash; 2026-08-25 live)
Pace Management API calls by x-ratelimit-remaining, honour retry-after: 60, and do not shard across PATs.The limit is 120 per user per scope, 1:1 decrement on a scoped read, and the JSON 429 carries that header. The counter is per user, so a second PAT drains the same bucket.rate-limits L01, L01b (2026-08-17/18)
Provision dedicated projects with a smart region group rather than a pinned city unless a tenant requires one.region_selection: {type: "smartGroup", code: "apac"} was accepted (201) on a Pro org, landed ap-northeast-2, healthy in 135 s.instance-sizing I02 (2026-08-17)
Do not restore a paused free-era project inside a paid org.After the wake, POST /pause answers 400 Project is not free-tier.instance-sizing I03 (2026-08-18)
Delete the project, or move it to a Free org, unless it may join the always-on floor.A project that should stay parked has to be dealt with before the restore.instance-sizing I03 (2026-08-18)
Read pause eligibility from the API before designing around it.GET /v1/organizations/{slug} carries plan; GET /v1/organizations/{slug}/entitlements is a flat list keyed by feature.key, and project_pausing reads hasAccess: true on the platform plan (pause 200 -> INACTIVE) and false on Pro and Team.org-consolidation entitlements read (2026-08-03) for Pro and Team; sfp-platforms S06 (2026-08-24/25) for platform; platform-facts F01 (2026-09-02) for the payload shape
Offer JIT database access for time-boxed contractor access only on a platform-plan org.POST /database/jit/invite; the identical invite on Pro is rejected with 500.sfp-platforms S15 (2026-08-25, A/B)

One item, down from four.

  • Scale / noisy-neighbour. Everything above used 2 tenants and a handful of rows, so nothing here measures many-tenant contention or the amortisation claim under load. This one is not pending, it is out of reach: a throwaway Micro instance cannot tell you how many tenants fit on a real one, and a cheap version of the test would produce a number that reads as capacity while measuring the hardware it ran on. What it would take is a properly-sized instance and a few thousand tenants, which is a spend rather than an afternoon. The amortisation claim is reasoning; it was not measured. Size for aggregate load with the usual caution.

The other three closed. External-IdP portability across projects and the promotion migration were measured on 2026-08-03, and the gateway on 2026-08-04 - that one removed a component instead of confirming it, because placement turned out to be a runtime lookup and ref-hiding a project setting. All three now have rows in the table below. The trust wiring is in the shared-tenancy guide; the promotion migration and the placement result are in the promotion guide.

The core claim - shared-instance RLS gives real, forge-resistant per-tenant isolation through the live Data API - is tested (Proofs 1-3). The surrounding control plane is architecture, and should be treated as such until built and tested the same way.

ClaimHow it was checked
Paid projects cannot be pausedpausing docs + pricing matrix
Pro $25/mo, $10 credit = one Micro, Micro $10/mopricing page
Micro = ci_micro, $0.01344/hr, 1 GB / 60+200 connslive GET /v1/projects/{ref}/billing/addons
RLS isolation, with check, fail-closed, bypassProof 1 - Postgres 17, 7/7 pass
Isolation through real PostgREST + GoTrue JWTsProof 2 - live project, 6/6 pass
Placement discovery replaces the gatewayMeasured 2026-08-04. A client that reads its {ref, key} at runtime followed a tenant from the shared project to a dedicated one with 0 password logins, reading its rows at the destination with the refresh token it already held; the other tenant was unaffected and the only hosts contacted were the two projects. The old placement kept serving until the source identity was retired
Ref-hiding needs no proxyMeasured 2026-08-04. POST /v1/projects/{ref}/vanity-subdomain/check-availability then /activate both returned 201 and the tenant-facing hostname carries no project ref. Note the custom domains doc says vanity subdomains are configurable “via the CLI only” - the Management API accepts them directly. check-availability answers 201, not 200, and wants a bare label rather than a hostname
MFA survives promotionMeasured 2026-08-04. A real TOTP factor, enrolled and verified at the source, copies across verified, and the same secret reaches aal2 at the destination
Retiring the source identity closes itMeasured 2026-08-04. After DELETE /auth/v1/admin/users/{id} the source refuses both the password grant (invalid_credentials) and the previously issued refresh token (refresh_token_not_found), while the destination is unaffected. The tenant’s rows remain on the source
Create -> healthy, and healthy is not writableMeasured 2026-08-04, n=5 (supersedes a single 138 s run). Create -> ACTIVE_HEALTHY 131-159 s, median 131; delete ~2 s; all 5 projects then refused their first admin/users write with 500 unexpected_failure and accepted the second, one poll tick later
Issuer key rotation is survivableRefuted 2026-08-03. A trusting project accepted no new-key token within 20 minutes and honoured a revoked key for 15. Re-creating the integration does not help - the issuer’s own JWKS lags as well. Rotation is a maintenance window, which is a design input for choosing this pattern at all. Re-measured 2026-08-04 with both key sets re-read at every probe: the issuer published the new key in about 7 minutes while the trusting project never re-resolved at all in 37, so the window is the consumer’s cache rather than the issuer’s publication - which is also why re-creating the integration cannot fix it. Re-run live 2026-08-25 (key-rotation R01-R03, published artifacts): the hub published the new kid in 241 s against about 7 minutes on 2026-08-04; the cached kid set did not change across 116 probes in 20 minutes and new-key tokens were refused (401) throughout; and a fresh integration created while the hub’s JWKS already advertised the new key still served the original kid set. Two things changed around the finding: signing keys are managed at /v1/projects/{ref}/config/auth/signing-keys (the project’s /auth/v1/admin/signing-keys is a 404), and the standby-create rate limit seen on 2026-08-04 (Please wait until <ISO8601>, 144 s, 127 s and 131 s) is gone - 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 constraint is one standby at a time, with no wait. The revoked-key result was not re-confirmed on 2026-08-25: the module order meant the trusting project never cached the revoked key, so the 15-minute figure stands on the 2026-08-03/04 runs alone
A promoted tenant can be made independentMeasured 2026-08-03. Copying one auth.users row gives the dedicated project its own issuance; adding identities, sessions and refresh_tokens with a sequence resync ports the live session, so promotion costs zero re-logins. Unsupported (writes into the auth schema) but tested
A promoted tenant is independent by defaultRefuted 2026-08-03. The dedicated project verifies the issuer’s tokens but cannot mint them - refresh returns 400 refresh_token_not_found, and after deleting the issuer its already-issued tokens kept working while nothing new could be issued. The issuer is on the availability critical path; blast radius is one token lifetime (3600 s default). Re-measured 2026-08-18 with a reproducible module: an issuer-minted refresh token presented to the trusting project answers {"code":400,"error_code":"refresh_token_not_found","msg":"Invalid Refresh Token: Refresh Token Not Found"} while the same token kind succeeds at the issuer
A tenant cannot forge its tenant_id (app_metadata admin-only)Proof 3 - live project, 3/3 attacks fail; re-measured 2026-08-03 on two fresh projects - PUT /auth/v1/user returns 403 Updating app_metadata requires admin privileges, signup silently drops the field, and an admin write moves the boundary as a positive control
Third-party auth: any JWKS issuer + role claim worksMeasured 2026-08-03 - oidc_issuer_url and jwks_url both resolve on the create response, custom_jwks never resolves, and one project’s token is accepted by another only while the integration exists
SfP is gated, Nano-only scale-to-zerosupabase-for-platforms - partially superseded by the two measured rows below (2026-08-24)
SfP scale-to-zero is the platform plan’s default computeMeasured 2026-08-24. On a platform-plan organization, the SfP-prescribed create (no desired_instance_size) provisions a nano project by default - the org-scoped list reports infra_compute_size: "nano" and shared_buffers reads 224MB, against the paid-Org default of micro (256MB). Nano projects can pause (scale-to-zero economics). Nano appears in neither the addon catalogue nor the resize path (compute_update_available_sizes, ci_micro..ci_16xlarge) on any plan - it is the create default, not a variant you can select or resize to. The earlier “Nano absent” reading measured the upgrade catalogue, not the default, and is corrected here
The platform plan is decoupled from the SfP form-gatesMeasured 2026-08-24 on a platform-plan organization. The plan is an entitlements tier, not the “contact us” gate: the entitlements endpoint grants unlimited Realtime (10000 concurrent), branching, functions and custom-OAuth providers, 366-day audit logs, and an 18-variant compute catalogue from ci_micro up to ci_48xlarge_high_memory (the self-service update path is a narrower 10 sizes, ci_micro..ci_16xlarge) - plus, unlike every paid plan, project_pausing: true, which is enforced (pause 200 -> INACTIVE; restore -> healthy; a normal paid org answers 400 not free-tier). It does not grant the form-gated surfaces as documented: restore points 400, and the OAuth project-claim / apps / transfer bridge is 404. project_cloning: true is declared but has no Management API endpoint (404). The migrations endpoint is not actually SfP-gated: it answers 200 (transactional rollback verified) on a normal Pro org too, so the “contact us” framing in the guide overstates the gating
Platform-plan read replicasMeasured 2026-08-24; gate identified 2026-08-25. instances.read_replicas: true is declared, but POST /projects/{ref}/read-replicas/setup answers 400 - and the body names the gate: "Read replicas require a minimum size of small", identical on platform and Pro orgs. The platform plan’s nano create default sits below the replica compute floor, so the earlier “entitlement is not the endpoint gate” reading resolves to infra prerequisites: the compute floor, then a completed physical backup (after ci_small the refusal becomes a completed-backup wait). Chain closed on Pro: after enabling pitr_7 the setup is accepted (204) - prerequisites are exactly the compute floor plus a completed physical backup. PITR enable on the platform org is its own boundary: 400 "Organization is not entitled to the selected PITR duration"
Platform-plan JIT database accessMeasured 2026-08-25, both org classes. POST /projects/{ref}/database/jit/invite answers 200 with an invite_id on a platform org (time-boxed, network-restricted, role-scoped grants; delete 200) - and the identical invite on a Pro org is rejected (500). One of the few surfaces that is a genuine platform-plan differentiator rather than an infra prerequisite
Backup schedule config is Enterprise-gated everywhereMeasured 2026-08-25, both org classes. GET /projects/{ref}/database/backups/schedule answers a structured 402 entitlement_required (error.feature: backup.schedule) on platform AND Pro orgs; the endpoint’s own spec text says it requires the Enterprise plan. The only backup knob it would grant is the daily-backup time of day
Platform-plan disk modificationMeasured 2026-08-24. Fresh project disk is gp3 2GB / 3000 IOPS / 125 MiB/s. POST /config/disk requires the full attribute set (type is a required discriminator: gp3 or io2); the grow is async (201 empty body, reflects ~15 s later). gp3 IOPS is floored at 3000 with a max of min(500 x size_gb, 16000), so growing a 2GB disk to 4GB is impossible (3000 > 2000) - the first valid grow is to 6GB+
Platform-plan read-only modeMeasured 2026-08-24. GET /projects/{ref}/readonly -> {enabled: false, override_enabled: false}; POST /readonly/temporary-disable -> 201 (a 15-minute override, reflected as override_enabled)
Platform-plan member listingMeasured 2026-08-24. GET /organizations/{slug}/members returns full member objects (user_id, role_name, mfa_enabled) on a platform org - populated, not the read-only stub a normal org sees
Platform-plan backup schedule boundaryMeasured 2026-08-24. GET /database/backups/schedule -> 402 with a structured entitlement_required error carrying error.feature = "backup.schedule" (the entitlement boundary is a clean error code, not a 404)
Migration versioningMeasured 2026-08-24. POST /database/migrations returns [] (no version in the response); the version is a YYYYMMDDHHMMSS timestamp in supabase_migrations.schema_migrations. GET/PATCH /database/migrations/{version} by that timestamp both answer 200
Platform-plan branch lifecycleMeasured 2026-08-24. POST /projects/{ref}/branches -> 201 (the create response carries a UUID id, not the name; the branch listing then shows 2: parent + branch). Deletion is the top-level endpoint by branch id - DELETE /v1/branches/{id} -> 200 - not DELETE /projects/{ref}/branches/{name} (that answers 404)
secret_jwt_template on API keysMeasured 2026-08-24. POST /api-keys with secret_jwt_template is accepted (201) and the template is echoed on the key. The minted key is opaque (sb_secret_..., not a self-contained JWT), so the template shapes the issued token server-side and is not inspectable from the key itself
JIT database access invitationsMeasured 2026-08-24. POST /database/jit/invite (email + role + expires_at + allowed_networks) -> 200 with an invite_id; DELETE /database/jit/invite/{id} -> 200. A time-boxed, network-restricted, role-scoped grant - the managed answer to giving a contractor or customer temporary DB access
Nano unreachable on a normal paid organizationMeasured 2026-08-17. Create with desired_instance_size: "nano" -> 400 Minimum instance size on paid plans is Micro; ci_nano addon PATCH -> 400 addon_variant: Invalid input; ci_nano absent from available_addons entirely
Legacy free-era project: one-way pause doorMeasured 2026-08-18. A project created under the free plan keeps its paused state after the organization upgrade; restore accepted (wake beyond a 20-min bound, healthy later); POST /pause after the wake -> 400 Project is not free-tier. Please downgrade it to free-tier first and try again.
Smart region selection on a normal paid organizationMeasured 2026-08-17. region_selection: {type: "smartGroup", code: "apac"} accepted (201), the platform picked ap-northeast-2, healthy in 135 s - deferred city placement is not SfP-gated
Management API rate-limit surfaceMeasured 2026-08-17/18. x-ratelimit-limit/remaining/reset on every response; limit 120 with a 1:1 decrement on a scoped read; a burst trips JSON 429 {"message":"ThrottlerException: Too Many Requests"} with retry-after: 60 and recovers after the window. The budget is cumulative across a user’s PATs (each token’s remaining drops on the other token’s calls) - PAT sharding does not multiply it
OAuth authorize surface (Path B)Measured 2026-08-17/18. Bogus client_id -> 422 Unrecognized client_id (client validation before session validation); project-claim -> 404 for a normal organization’s credential class; jwt-bearer grant validates params before gating. Lifecycle: 24 h access tokens, refresh rotates the refresh token, grants are organization-scoped to the approved organization, revocation instant (204, then 404 on the next refresh)
DIY per-tenant usage meteringMeasured 2026-08-17/18. Storage listing exact to the byte; usage.api-counts exact (13/13) at 61 s lag; Prometheus endpoint 326+ families via PAT; scoped-key gateway scrape 200 with 278 families, 403 deny-by-default on unclassified path and other refs, audit feed ~1 s. The control-plane store, idempotent rollup and invoice reconciliation are modules M05-M07 in usage-metering

All live measurements were taken on throwaway projects, created and deleted for the run that produced them. They span six dates rather than one: the cost figures and Proofs 1-3 are from 2026-07-09 (Postgres 17.6), the trust, promotion and rotation results from 2026-08-03, the discovery, ref-hiding, MFA, retirement and provisioning results from 2026-08-04, the Nano, legacy-pause, smart-region, rate-limit, OAuth-surface and metering results from 2026-08-17/18 - the last four dates in ap-southeast-1 on Micro compute - and the platform-plan (SfP) entitlement rows from 2026-08-24, measured on a platform-plan org via the self-provisioning sfp-platforms battery. The for all policy behaviour noted above was checked separately on Postgres 17.10. The rows dated 2026-08-25 (the key-rotation re-run, the replica gate, the JIT and backup-schedule A/B) are later additions and carry their dates inline; the 2026-09-02 entitlements re-read is cited from the practices table only. Where a row says documented rather than measured, it was read from the platform docs on the date given and not executed.

ModuleExperimentTestArtifact
C05tenant-consolidationc05-rls-after-merge.tsnone published
F01platform-factsf01-entitlements.tsnone published
I02instance-sizingi02-smart-region.tsnone published
I03instance-sizingi03-legacy-pause-lifecycle.tsnone published
L01rate-limitsr01-rate-limit-surface.tsnone published
L01brate-limitsr01-rate-limit-surface.tsnone published
R01key-rotationr01-rate-limit.tsout/2026-08-25
R01bkey-rotationr01-rate-limit.tsout/2026-08-25
R02key-rotationr02-no-reresolve.tsout/2026-08-25
R03ckey-rotationr03-revoked-key.tsout/2026-08-25
S06sfp-platformss06-platform-entitlements.tsnone published
S14sfp-platformss14-secret-jwt-template.tsnone published
S15sfp-platformss15-jit-database-access.tsnone published
X01cross-project-authx01-tpa-shapes.tsnone published
X03cross-project-authx03-refresh-to-issuer.tsnone published
If you are askingGo to
Should I share an instance, and what does it save?This doc - the cost model and the decision above
Build me the shared tierRunning many tenants on one Supabase project
I already have a project per customerConsolidating one Supabase project per customer onto a shared project
A tenant converted, give it its own projectPromoting a tenant to its own Supabase project
I just want one billConsolidating Supabase accounts into one organization - not a tenancy move: it changes the billing and admin container and leaves the tenant-to-project mapping alone
  1. Supabase, “Pricing,” Supabase. https://supabase.com/pricing

  2. Supabase, “Free project pausing,” Supabase Docs. https://supabase.com/docs/guides/platform/free-project-pausing

  3. Supabase, “Supabase for Platforms,” Supabase Docs. https://supabase.com/docs/guides/integrations/supabase-for-platforms

  4. Supabase, “Compute usage,” Supabase Docs. https://supabase.com/docs/guides/platform/manage-your-usage/compute

  5. Supabase, “Third-party auth,” Supabase Docs. https://supabase.com/docs/guides/auth/third-party/overview

  6. Supabase, “Auth MFA,” Supabase Docs. https://supabase.com/docs/guides/auth/auth-mfa

  7. Platform Kit - the embeddable dashboard block; the default wiring puts a Management API personal access token server-side.

  8. Build a Supabase OAuth integration - the Management API OAuth2 flow (authorize, token exchange, refresh, revoke).