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.

Everything with a number attached is either from the public Supabase pricing page 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. It is a copy, not a cutover: both projects answer for the tenant until you retire the source identity, which 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.” - free-project-pausing.
  • 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/moorg-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

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 amortized 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 (the load-bearing choice). 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 auth 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 session - 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

This is the difference between safe and broken: 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 load-bearing - and now tested, not assumed.

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 wins decisively on idle cost and add-tenant latency, matches on always-warm responsiveness, and loses on isolation strength and per-tenant portability - which is exactly why you keep Tier D for tenants that have earned (and are paying for) those properties.

  • 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

Two edges worth reading twice. 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.

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 amortization 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. Treat the amortization claim as reasoning, not as measurement, and 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
Hub 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
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 hub’s tokens but cannot mint them - refresh returns 400 refresh_token_not_found, and after deleting the hub its already-issued tokens kept working while nothing new could be issued. The hub is on the availability critical path; blast radius is one token lifetime (3600 s default)
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

All live measurements were taken on throwaway projects, created and deleted for the run that produced them. They span three 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, and the discovery, ref-hiding, MFA, retirement and provisioning results from 2026-08-04 - the last two dates in ap-southeast-1 on Micro compute. The for all policy behaviour noted above was checked separately on Postgres 17.10. Where a row says documented rather than measured, it was read from the platform docs on the date given and not executed.

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