Consolidating one Supabase project per customer onto a shared project
You gave every end customer their own Supabase project. It was the right call
at five customers. At fifty customers that is fifty compute floors, fifty
migrations to apply, fifty sets of keys. You want one project with a
tenant_id column and Row Level Security.
This guide is the runbook for that move. It assumes you have decided to make it -
the cost model argues the case,
running many tenants on one project builds
the tier this lands on, and
promoting a tenant out is the opposite
direction. Prerequisites: a Personal
Access Token in SUPABASE_ACCESS_TOKEN, the service_role key for each
project, and jq.
Measurement provenance
Section titled “Measurement provenance”Measured 2026-08-04 against three throwaway Supabase projects in
ap-southeast-1 on Micro compute: two playing per-customer sources, one playing
the consolidation target. 34 assertions, 33 pass and one failure that is the
substance of this guide. Claims marked measured in the evidence table were
executed and captured; the rest are marked.
Three users and three orders per source. Nothing here measures behaviour at scale - a merge of two customers says nothing about the wall-clock cost of merging fifty, and that limitation is carried into the evidence table rather than dropped.
Constants
Section titled “Constants”| Fact | Value |
|---|---|
| The uniqueness a user merge must satisfy | users_email_partial_key: UNIQUE (email) WHERE (is_sso_user = false) |
| What that index is over | the raw email column - a plain btree, so it is case-sensitive |
Non-generated columns in auth.users | 34 (measured; it differs across auth versions, which is why you enumerate rather than hardcode) |
| Admin create endpoint | POST /auth/v1/admin/users |
| The three fields that make it a migration | id, password_hash, app_metadata |
| Duplicate email address, admin endpoint | 422 email_exists - case-insensitive |
| Duplicate email address, SQL copy | 23505 on users_email_partial_key - case-sensitive |
| Cost of one duplicate in a bulk insert | the whole statement: one source contributed 0 of its 2 users |
| Role behind the Management API query endpoint | postgres - RLS does not constrain it |
| PostgREST preference for testing writes | return=minimal; the default reports a permitted write as 403 42501 |
Merging is not promotion run backwards
Section titled “Merging is not promotion run backwards”Splitting a shared project into a dedicated one cannot produce a conflict: every value you carry out was unique in the project it came from. Merging two projects that were provisioned independently produces a conflict for every namespace they both allocated from, because neither could have known about the other.
Three namespaces collide, in increasing order of how much of the plan they change:
| Namespace | Collides on | Fix |
|---|---|---|
| Surrogate keys | every bigserial both sides allocated from 1 | scope the key: primary key (tenant_id, id) |
| Sequences | the merged table’s own sequence still starts at 1 | setval past the highest migrated id |
| Email addresses | one human with an account at two of your customers | a product decision - see Part 2 |
The first two are mechanical. The third is a product decision.
Part 1: inventory the collisions before moving anything
Section titled “Part 1: inventory the collisions before moving anything”Run this on every source project and diff the outputs. It is the only step whose result can change the design.
-- Email addresses. Lower-cased, because the target's index is not, and you want to-- find near-duplicates before they become two accounts.select lower(email) as email from auth.users where deleted_at is null order by 1;
-- Every surrogate key that will have to survive the merge, and its type.select c.relname as table_name, a.attname as column_name, format_type(a.atttypid, null) as type from pg_class c join pg_namespace n on n.oid = c.relnamespace join pg_attribute a on a.attrelid = c.oid join pg_index i on i.indrelid = c.oid and i.indisprimary and a.attnum = any(i.indkey) where n.nspname = 'public' and c.relkind = 'r' order by 1;Count the overlapping email addresses. That number is the size of the only part of this migration you cannot automate.
Part 2: decide what happens to a human who exists twice
Section titled “Part 2: decide what happens to a human who exists twice”The target has one GoTrue. users_email_partial_key makes an email address unique
across the whole project, so the same person cannot hold an account at two of
your customers under one email address. Three ways out; the third is the only
one that keeps one identity per person.
| Option | What the person experiences | What it costs you |
|---|---|---|
| Leave the second occurrence behind | absent from that customer on the new platform | a re-invite per person, and the customer notices |
Rewrite the email address (person+tenant-b@example.com) | a login string that changed | comms; password resets and magic links now go to the tagged email address, and the person has two passwords again |
| One account, many memberships | one login, a tenant switcher | the model changes: tenant_id stops being a scalar claim |
Measured: the rewrite works and costs nothing technically - the row lands and the original password still authenticates. It is the human-facing cost that is real.
Part 3: move the users
Section titled “Part 3: move the users”Do not write into the auth schema. It works - the copy carries the uuid, the
password hash and 34 columns - but it is unsupported, and measured against the
documented endpoint it buys nothing and loses the case-insensitivity that
protects you from silently creating two accounts for one person.
POST /auth/v1/admin/users takes all three fields that make this a migration
rather than a re-registration:
(Promotion moves users the other way and does not use this endpoint, because the admin API cannot carry a session. Moving users between projects sets the two mechanisms side by side.)
# On the source: the rows to carry.psql "$SOURCE_DB_URL" -At -F$'\t' -c \ "select id, email, encrypted_password from auth.users where deleted_at is null" \| while IFS=$'\t' read -r id email hash; do curl -sS -o /dev/null -w '%{http_code} '"$email"'\n' \ -X POST "https://$SHARED_REF.supabase.co/auth/v1/admin/users" \ -H "apikey: $SHARED_SERVICE_ROLE" \ -H "Authorization: Bearer $SHARED_SERVICE_ROLE" \ -H "Content-Type: application/json" \ -d "$(jq -nc --arg id "$id" --arg email "$email" --arg hash "$hash" --arg t "$TENANT" \ '{id:$id, email:$email, password_hash:$hash, email_confirm:true, app_metadata:{tenant_id:$t}}')" doneWhat each field buys, all measured:
password_hashtakes the source’s bcrypt string ($2a$...) as-is, and the migrated user logs in at the target with the password they already had. The plaintext is never needed and nobody is sent a reset. Control: the same user created without password material returns400 invalid_credentialson login, so the result is attributable to this field and not to something else. This works because both ends are Supabase and the hash is already bcrypt - GoTrue dispatches on the hash prefix and returns HTTP 500 for a format it has no verifier for, which is a different migration entirely: migrating PBKDF2 password hashes into Supabase Auth.idis honoured, so everyuser_idalready stored in that customer’s data still resolves. Without it you would remap every foreign key.app_metadatasets the tenant claim at creation. This step has no counterpart in the promotion direction: the source rows carry notenant_idat all, because the project was the tenant.
Two more properties matter before you plan the cutover:
- The source is untouched. It still authenticates the same people afterwards, so a consolidation can be abandoned after the first customer.
auth.identitiesis not needed for password login. A migrated user authenticates with zero identity rows on the target. Social and SSO identities are a separate problem this run did not test.
Part 4: move the data
Section titled “Part 4: move the data”Keep the customer’s identifiers. Order numbers are printed on invoices, quoted in support tickets and embedded in URLs, and reassigning them turns a migration into a support load.
-- Target: the key is scoped to the tenant, so both customers keep 1..n.create table public.orders ( tenant_id text not null, id bigint not null, sku text not null, user_id uuid references auth.users(id), primary key (tenant_id, id));create index on public.orders (tenant_id);Measured: both sources merge into that shape with ids intact - six rows over
three distinct ids. The naive version, keeping id as the sole primary key,
refuses the second customer with 23505.
user_id needs no remapping because Part 3 preserved the uuids. Tables keyed by
uuid merge with no special handling at all, which is what makes the collision
a property of key allocation rather than of merging.
Then the step that is easy to skip, because it does not fail during the migration:
-- The merged table's sequence still starts at 1, which is a live id for every-- tenant you just imported.select setval( pg_get_serial_sequence('public.orders', 'id'), (select max(id) from public.orders), true);Without it the first write after cutover fails with 23505, in production,
on a table that migrated cleanly.
Part 5: turn isolation on
Section titled “Part 5: turn isolation on”Before the merge, isolation was an instance boundary. After it, isolation is a predicate on a claim, and the order of these statements decides whether the application is briefly blank or briefly wrong.
The claim design itself - why tenant_id belongs in app_metadata rather than
user_metadata, and what a tenant can do to a claim it can write - is Part 2 of
the shared-tenancy guide and
is not repeated here. What follows is only what changes when the rows already
exist.
-- 1. The column, backfilled and constrained, BEFORE any policy exists.alter table public.orders add column if not exists tenant_id text;update public.orders set tenant_id = 'tenant-a' where tenant_id is null;alter table public.orders alter column tenant_id set not null;
-- 2. RLS and the policy in the same migration. Between them, the table is-- deny-all: measured, a tenant gets HTTP 200 and zero rows.alter table public.orders enable row level security;create policy tenant_isolation on public.orders using (tenant_id = (auth.jwt() -> 'app_metadata' ->> 'tenant_id')) with check (tenant_id = (auth.jwt() -> 'app_metadata' ->> 'tenant_id'));Then find what you missed. Every table that was isolated by the instance boundary and is now isolated by nothing:
select c.relname from pg_class c join pg_namespace n on n.oid = c.relnamespace where n.nspname = 'public' and c.relkind = 'r' and not c.relrowsecurity order by 1;Measured: a table left off that list is readable in full by every tenant. One missed table is a cross-tenant read, which is why “RLS on every table” is a requirement rather than a slogan.
What to do about it
Section titled “What to do about it”The practices this runbook carries, collected with the module each rests on. A practice with no module id is a design choice rather than a result.
| Practice | Evidence | Module |
|---|---|---|
Retry the first admin create on a fresh target, expecting 500 unexpected_failure. | The Part 3 loop prints %{http_code} per user, so the first user would otherwise be logged as a failure that is the platform settling rather than a duplicate: 5 of 5 fresh projects refused the first admin/users write after ACTIVE_HEALTHY and took the second. | placement reference, 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 every source project alive until Verification check 1 passes, then delete it. | Check 1 is a real login at the target, for all of that customer’s users. The copy is non-destructive - the source still authenticates afterwards (HTTP 200) - which is what makes the migration abandonable per customer. | C01, C03 (2026-08-04) |
After Part 4, count orphaned references with select count(*) from public.orders o where not exists (select 1 from auth.users u where u.id = o.user_id) and expect 0. | A non-zero count means id was dropped from an admin create and that customer’s foreign keys point at nothing. | C03 (2026-08-04) - the check turns the honoured id into a gate |
Pace the Part 1 inventory and Part 5 audit queries over POST /v1/projects/{ref}/database/query by x-ratelimit-remaining. | Across many sources the Management API budget is 120 per user per scope, the 429 is JSON with retry-after: 60, and a second PAT from the same user drains the same counter. | rate-limits L01, L01b (2026-08-17/18) |
When rehearsing the merge on staging, truncate ... restart identity in the cleanup, covering every value a test can write. | Three verdicts flipped in the lab because each test measured its own leftover state: one produced a real 23505 against the previous rehearsal’s row, one lost the reason for a collision, and one stopped reproducing a collision at all. | tenant-consolidation RUNLOG, harness lessons (2026-08-04) |
Verification
Section titled “Verification”Four checks, in the order in which they can lie to you.
1. The users arrived with their passwords. Not “the rows are there” - an actual login, with the password from before the merge:
curl -sS -X POST "https://$SHARED_REF.supabase.co/auth/v1/token?grant_type=password" \ -H "apikey: $SHARED_ANON" -H "Content-Type: application/json" \ -d '{"email":"person@example.com","password":"<their existing password>"}' \| jq '{ok: (.access_token != null), tenant: (.user.app_metadata.tenant_id)}'Expect {"ok": true, "tenant": "tenant-a"}. A 400 invalid_credentials here
means password_hash was dropped, and every user is facing a reset.
2. Nobody was split in two. The target’s index is case-sensitive, so ask in lower case:
select lower(email), count(*) from auth.users group by 1 having count(*) > 1;Expect zero rows. Any row here is a person whose login lands in one tenant or the other depending on the attempt - see the gotcha below.
3. Isolation holds for a tenant, not for you. Read through PostgREST with a real tenant token, not through the SQL console:
curl -sS "https://$SHARED_REF.supabase.co/rest/v1/orders?select=tenant_id&tenant_id=eq.tenant-a" \ -H "apikey: $SHARED_ANON" -H "Authorization: Bearer $TENANT_B_TOKEN" | jq lengthExpect 0. Measured: the same query as the console’s postgres role returns
every row, because RLS does not constrain it.
4. The write half. Ask for return=minimal and then count server-side,
because the default conflates two different outcomes:
curl -sS -o /dev/null -w '%{http_code}\n' \ -X POST "https://$SHARED_REF.supabase.co/rest/v1/orders" \ -H "apikey: $SHARED_ANON" -H "Authorization: Bearer $TENANT_B_TOKEN" \ -H "Content-Type: application/json" -H "Prefer: return=minimal" \ -d '{"tenant_id":"tenant-a","id":9001,"sku":"forged"}'Expect 403, and then confirm no row landed.
Gotchas and lessons learned
Section titled “Gotchas and lessons learned”One duplicate email address costs the whole customer. A bulk insert is one statement and therefore atomic. Measured: a source offering two users, one of whom already existed, contributed zero. Migrate per user and collect the failures.
A SQL copy admits a case-variant of an email address, and the resulting login is
non-deterministic. The index is a btree over the raw column, so
Person@example.com lands alongside person@example.com. This is not a
cosmetic duplicate. Over five login attempts at each casing, all returned HTTP
200, both accounts were reachable from both input strings, and the mapping
changed mid-sequence:
lower -> f163, f163, f163, ae87, ae87upper -> ae87, ae87, f163, f163, f163Two accounts, two different tenant_id claims, and which one the person gets is
decided per attempt. The admin endpoint normalises case and refuses the second
with 422 email_exists, which the SQL path does not.
The migration console is not constrained by the policy you are testing. The
Management API query endpoint connects as postgres. Measured: it returned all
three rows while the tenant returned zero. Verifying that data landed and
verifying that isolation works are different questions, and only one of them can
be answered from the SQL editor.
PostgREST’s default return=representation reports a permitted write as
403 42501. RETURNING is filtered by the SELECT policy, so a row a tenant is
allowed to insert but not allowed to see fails the statement and rolls back.
Measured against a deliberately open policy: the write was permitted, one row
landed under return=minimal, and the identical write under the default came
back 403. Any RLS write test written against PostgREST defaults reports an open
hole as closed.
using without with check is not the hole people think it is. For a FOR ALL policy, Postgres reuses the USING expression as the check when no WITH CHECK is given: measured, a forged insert was refused and left zero rows. The
hole that does exist is with check (true), which someone adds when an insert
starts failing and the cause is not obvious. Measured: with it, one tenant wrote
a row attributed to another while every read test kept passing.
The sequence fails on the first write after cutover. It surfaces long after the migration has been declared successful.
Enabling RLS before backfilling tenant_id reads as an outage. Deny-all with
no policy returns HTTP 200 and an empty array, so the application goes blank
rather than erroring. Keep the enable and the policy in one migration.
Evidence
Section titled “Evidence”| Claim | How it was checked | Result |
|---|---|---|
| Admin create accepts an existing bcrypt hash | POST /auth/v1/admin/users with the source’s encrypted_password as password_hash | Measured - HTTP 200, and the user logs in with the original password |
That result is attributable to password_hash | Control: same create with no password material, then login | Measured - 400 invalid_credentials |
A supplied id is honoured | Compared the created user’s uuid with the source’s | Measured - identical, so stored user_id values still resolve |
app_metadata at creation reaches the token | Decoded the access token after login | Measured - tenant_id present |
| Duplicate email address on the admin endpoint | Second create with an existing email address | Measured - 422 email_exists |
| The admin endpoint normalises case | Create with an upper-case variant of an existing email address | Measured - 422 email_exists, one row for that person |
| The SQL path does not | Copied the same email address upper-cased into auth.users | Measured - accepted, two rows differing only by case |
| Which of the two a login reaches | 5 login attempts per casing, comparing the sub claim | Measured - both reachable from both inputs, mapping unstable across attempts |
| One duplicate costs the whole customer | Bulk-copied a source holding one conflicting and one clean user | Measured - 23505, 0 of 2 landed |
auth.users copies many-to-one | Catalog-enumerated column copy via json_populate_recordset | Measured - 3/3 rows over 34 non-generated columns |
| Password and uuid survive the SQL path too | Login at the target, uuid comparison | Measured - both hold |
| Identity rows are not needed for password login | Counted auth.identities on the target after a users-only copy | Measured - 0 rows, login still succeeds |
| The copy is non-destructive | Logged in at the source afterwards | Measured - HTTP 200 |
| Both sources allocate the same surrogate keys | Read primary keys on two independently seeded sources | Measured - {1,2,3} on each |
A single-column id primary key refuses the second source | Merged both into it | Measured - 23505 on the primary key |
primary key (tenant_id, id) keeps ids intact | Merged both into it | Measured - 6 rows over 3 distinct ids |
uuid keys merge with no handling | Merged a uuid-keyed table from both sources | Measured - 4 rows, no collision |
| The first write after the merge collides | Inserted a new row without resyncing the sequence | Measured - 23505; setval fixes it |
| RLS with no policy denies the tenant | Read as a tenant between the enable and the policy | Measured - HTTP 200, 0 rows |
| The query endpoint is not constrained by RLS | Same read via the Management API | Measured - role postgres, all rows |
| Tenant isolation on merged rows | Read as each tenant, and cross-tenant with an explicit filter | Measured - 2 of 3, and [] |
FOR ALL with only using governs writes | Forged insert with return=minimal, then counted | Measured - refused, 0 rows landed |
with check (true) is the write hole | Same insert under a permissive check | Measured - accepted, 1 row landed, reads still isolated |
return=representation masks that | Identical insert under the default preference | Measured - 403 42501 on a permitted write |
| A table without RLS is readable by every tenant | Read a non-RLS table as the other tenant | Measured - all rows |
| Social and SSO identity migration | Not exercised - the run was email and password only | Not tested |
| Wall-clock cost at real customer counts | Two sources, three users and three orders each | Not tested |
| Storage objects, Edge Functions, cron jobs, webhooks | Out of scope for this run | Not tested |
All measurements were taken on throwaway projects created and destroyed for this guide, on 2026-08-04.
File reference
Section titled “File reference”The harness for these runs lives in the supabase-lab repo under
experiments/tenant-consolidation/:
| File | What it holds |
|---|---|
tests/c01-users-merge-sql.ts | the SQL user copy into auth.users: the column copy, uuid and password survival, the source control |
tests/c02-email-collision.ts | the duplicate and case-variant conflicts: the whole-customer loss, the rewrite, the 5-attempt login mapping |
tests/c03-users-merge-api.ts | the admin API merge: the bcrypt password_hash, the supplied uuid, the tenant claim, the duplicate refusal, the no-password control |
tests/c04-key-collision.ts | the surrogate key collision: the single-column refusal, primary key (tenant_id, id), the setval resync |
tests/c05-rls-after-merge.ts | RLS after the merge: the deny-all, the unguarded table, the with check write hole, the 42501 mask |
lib/consolidate.ts | the shared helpers and fixture constants |
RUNLOG.md | the per-test narrative and the 2026-08-04 run (33 pass, 1 fail, 0 skip), pinned to the lab commit; the raw probes are not published because they carry project refs |
Modules
Section titled “Modules”| Module | Experiment | Test | Artifact |
|---|---|---|---|
| C01 | tenant-consolidation | c01-users-merge-sql.ts | none published |
| C03 | tenant-consolidation | c03-users-merge-api.ts | none published |
| L01 | rate-limits | r01-rate-limit-surface.ts | none published |
| L01b | rate-limits | r01-rate-limit-surface.ts | none published |
Related
Section titled “Related”- Running many tenants on one Supabase project
- the RLS and claim design this guide consolidates onto.
- Promoting a tenant to its own Supabase project
- the opposite direction. Not this guide run backwards: two projects provisioned independently collide on email addresses, keys and sequences, which a promotion out of one shared project never has to resolve.
- Consolidating Supabase accounts into one organization
- the cheaper move, when the projects are fine and only the billing and admin surface needs consolidating.