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_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. 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.
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.”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.
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 | organization-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”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 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 |
| Availability | anyone | anyone | gated |
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.
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 |
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.
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 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.
What you are trading away
Section titled “What you are trading away”Two ways to give your users a backend
Section titled “Two ways to give your users a backend”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_idat/v1/oauth/authorizeanswers422 {"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 - answers404 {"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-bearertoken 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.
Metering per-tenant usage
Section titled “Metering per-tenant usage”“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.
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.
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.
What to do about it
Section titled “What to do about it”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.
| Practice | Evidence | Module |
|---|---|---|
| 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) |
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 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.
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 |
| Issuer 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. 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 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 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 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 - partially superseded by the two measured rows below (2026-08-24) |
| SfP scale-to-zero is the platform plan’s default compute | Measured 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-gates | Measured 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 replicas | Measured 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 access | Measured 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 everywhere | Measured 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 modification | Measured 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 mode | Measured 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 listing | Measured 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 boundary | Measured 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 versioning | Measured 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 lifecycle | Measured 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 keys | Measured 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 invitations | Measured 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 organization | Measured 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 door | Measured 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 organization | Measured 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 surface | Measured 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 metering | Measured 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.
Modules
Section titled “Modules”| Module | Experiment | Test | Artifact |
|---|---|---|---|
| C05 | tenant-consolidation | c05-rls-after-merge.ts | none published |
| F01 | platform-facts | f01-entitlements.ts | none published |
| I02 | instance-sizing | i02-smart-region.ts | none published |
| I03 | instance-sizing | i03-legacy-pause-lifecycle.ts | none published |
| L01 | rate-limits | r01-rate-limit-surface.ts | none published |
| L01b | rate-limits | r01-rate-limit-surface.ts | none published |
| R01 | key-rotation | r01-rate-limit.ts | out/2026-08-25 |
| R01b | key-rotation | r01-rate-limit.ts | out/2026-08-25 |
| R02 | key-rotation | r02-no-reresolve.ts | out/2026-08-25 |
| R03c | key-rotation | r03-revoked-key.ts | out/2026-08-25 |
| S06 | sfp-platforms | s06-platform-entitlements.ts | none published |
| S14 | sfp-platforms | s14-secret-jwt-template.ts | none published |
| S15 | sfp-platforms | s15-jit-database-access.ts | none published |
| X01 | cross-project-auth | x01-tpa-shapes.ts | none published |
| X03 | cross-project-auth | x03-refresh-to-issuer.ts | none published |
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 |
References
Section titled “References”-
Supabase, “Pricing,” Supabase. https://supabase.com/pricing ↩
-
Supabase, “Free project pausing,” Supabase Docs. https://supabase.com/docs/guides/platform/free-project-pausing ↩
-
Supabase, “Supabase for Platforms,” Supabase Docs. https://supabase.com/docs/guides/integrations/supabase-for-platforms ↩
-
Supabase, “Compute usage,” Supabase Docs. https://supabase.com/docs/guides/platform/manage-your-usage/compute ↩
-
Supabase, “Third-party auth,” Supabase Docs. https://supabase.com/docs/guides/auth/third-party/overview ↩
-
Supabase, “Auth MFA,” Supabase Docs. https://supabase.com/docs/guides/auth/auth-mfa ↩
-
Platform Kit - the embeddable dashboard block; the default wiring puts a Management API personal access token server-side. ↩
-
Build a Supabase OAuth integration - the Management API OAuth2 flow (authorize, token exchange, refresh, revoke). ↩