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_idclaim 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.
The constraint that starts it all
Section titled “The constraint that starts it all”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.
What one idle tenant costs
Section titled “What one idle tenant costs”Billing is per organization for the plan, and per project, per hour for compute. From the pricing page:
| Item | Price | Note |
|---|---|---|
| Pro plan | $25/mo | org-level, flat; includes $10/mo compute credit |
| Compute credit | $10/mo | covers 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 mapstenant_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.
Architecture: tiered tenancy
Section titled “Architecture: tiered tenancy”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.
Moving users between projects
Section titled “Moving users between projects”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.
| Promotion | Consolidation | |
|---|---|---|
| What has to survive | the live session | the credential |
| Mechanism | insert into auth.users ... select from a JSON payload, plus auth.identities, auth.sessions and auth.refresh_tokens | POST /auth/v1/admin/users carrying id, password_hash and app_metadata |
| What it costs | writes into the auth schema, which is unsupported | the user signs in again, with the same password |
| The hard part | auth.users.confirmed_at is generated, so select * cannot work, and auth.refresh_tokens.id is a bigserial the destination must assign itself | two 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.
The model
Section titled “The model”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 inputcreate 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()); -- writesThe 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.
Proof 1 - mechanism, on Postgres 17
Section titled “Proof 1 - mechanism, on Postgres 17”Setting the role and request.jwt.claims GUC exactly as PostgREST does, inside
a transaction per tenant context:
| Test | Expectation | Result |
|---|---|---|
| Tenant A reads | only A’s rows | pass |
| Tenant B reads (same table) | only B’s rows | pass |
| A inserts a row tagged B | blocked by with check | pass (RLS violation) |
| A reassigns its row to B | blocked by with check | pass (RLS violation) |
| Unknown-tenant claim reads | nothing | pass (0 rows) |
| Missing claim reads | nothing (fail-closed) | pass (0 rows) |
service_role (BYPASSRLS) reads | all 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 |
|---|---|---|
| 1 | User A POST a tenant-A note | 201 Created |
| 2 | User B POST a tenant-B note | 201 Created |
| 3 | User A POST a row tagged tenant B | 403 - new row violates row-level security policy |
| 4 | User A GET /items | 200 - only A’s row |
| 5 | User B GET /items | 200 - only B’s row |
| 6 | Anon 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 = B | ignored; fresh token still shows app_metadata.tenant_id = A |
GET /items with the post-attack token | 200 - []; the seeded tenant-B secret stays invisible |
Public signup smuggling app_metadata.tenant_id = B | not 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.
The cost of the thing you are avoiding
Section titled “The cost of the thing you are avoiding”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.
Cost and feature comparison
Section titled “Cost and feature comparison”| One project per tenant | Shared tier (this doc) | SfP scale-to-zero | |
|---|---|---|---|
| Idle-tenant compute | $10/mo each (linear) | ~$0 marginal | $0 while asleep |
| Add a tenant | provision (131-159 s) | instant | provision |
| Isolation | hard (separate instance) | logical (RLS) | hard |
| Wake latency | n/a (always on) | none (always warm) | cold start |
| Per-tenant backup / portability | native | needs work (filtered dump) | native |
| White-label / own billing | yours to build | yours to build | built in |
| Availability | anyone | anyone | gated |
| Operational burden | medium | you own the isolation model | managed |
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.
What you are trading away
Section titled “What you are trading away”Decision
Section titled “Decision”- 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.
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.
Still design-only (not yet tested)
Section titled “Still design-only (not yet tested)”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.
Verified / tested
Section titled “Verified / tested”| Claim | How it was checked |
|---|---|
| Paid projects cannot be paused | pausing docs + pricing matrix |
| Pro $25/mo, $10 credit = one Micro, Micro $10/mo | pricing page |
Micro = ci_micro, $0.01344/hr, 1 GB / 60+200 conns | live GET /v1/projects/{ref}/billing/addons |
RLS isolation, with check, fail-closed, bypass | Proof 1 - Postgres 17, 7/7 pass |
| Isolation through real PostgREST + GoTrue JWTs | Proof 2 - live project, 6/6 pass |
| Placement discovery replaces the gateway | Measured 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 proxy | Measured 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 promotion | Measured 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 it | Measured 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 writable | Measured 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 survivable | Refuted 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 independent | Measured 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 default | Refuted 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 works | Measured 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-zero | supabase-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.
Related
Section titled “Related”| If you are asking | Go to |
|---|---|
| Should I share an instance, and what does it save? | This doc - the cost model and the decision above |
| Build me the shared tier | Running many tenants on one Supabase project |
| I already have a project per customer | Consolidating one Supabase project per customer onto a shared project |
| A tenant converted, give it its own project | Promoting a tenant to its own Supabase project |
| I just want one bill | Consolidating Supabase accounts into one organization - not a tenancy move: it changes the billing and admin container and leaves the tenant-to-project mapping alone |