Skip to content

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 and it is the wrong one at fifty: 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 - Shared-instance tenancy on Supabase argues the case and covers the opposite direction. Prerequisites: a Personal Access Token in SUPABASE_ACCESS_TOKEN, the service_role key for each project, and jq.

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.

FactValue
The uniqueness a user merge must satisfyusers_email_partial_key: UNIQUE (email) WHERE (is_sso_user = false)
What that index is overthe raw email column - a plain btree, so it is case-sensitive
Non-generated columns in auth.users34 (measured; it differs across auth versions, which is why you enumerate rather than hardcode)
Admin create endpointPOST /auth/v1/admin/users
The three fields that make it a migrationid, password_hash, app_metadata
Duplicate address, admin endpoint422 email_exists - case-insensitive
Duplicate address, SQL copy23505 on users_email_partial_key - case-sensitive
Cost of one duplicate in a bulk insertthe whole statement: one source contributed 0 of its 2 users
Role behind the Management API query endpointpostgres - RLS does not constrain it
PostgREST preference for testing writesreturn=minimal; the default reports a permitted write as 403 42501

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.

Before: one project per customerAfter: one project, many tenantsCustomer A projectGoTrue Aorders 1,2,3Shared projectone GoTruetenant_id on every rowRLS on every tableemail uniquenessis now globalCustomer B projectGoTrue Borders 1,2,3order idsnow collide

Three namespaces collide, in increasing order of how much of the plan they change:

NamespaceCollides onFix
Surrogate keysevery bigserial both sides allocated from 1scope the key: primary key (tenant_id, id)
Sequencesthe merged table’s own sequence still starts at 1setval past the highest migrated id
Email addressesone human with an account at two of your customersa product decision - see Part 2

The first two are mechanical. The third is not, which is why it comes first in the runbook.

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.

-- 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 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 address unique across the whole project, so the same person cannot hold an account at two of your customers under one address. Three ways out, and the third is the honest one if the overlap is not a rounding error.

OptionWhat the person experiencesWhat it costs you
Leave the second occurrence behindabsent from that customer on the new platforma re-invite per person, and the customer notices
Rewrite the address (person+tenant-b@example.com)a login string that changedcomms; password resets and magic links now go to the tagged address, and the person has two passwords again
One account, many membershipsone login, a tenant switcherthe 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.

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:

Terminal window
# 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}}')"
done

What each field buys, all measured:

  • password_hash takes 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 returns 400 invalid_credentials on 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.
  • id is honoured, so every user_id already stored in that customer’s data still resolves. Without it you would remap every foreign key.
  • app_metadata sets the tenant claim at creation. This step has no counterpart in the promotion direction: the source rows carry no tenant_id at all, because the project was the tenant.

Two more properties worth knowing 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.identities is 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.

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.

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.

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:

Terminal window
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:

Terminal window
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 length

Expect 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:

Terminal window
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.

One duplicate 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 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, ae87
upper -> ae87, ae87, f163, f163, f163

Two 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 is the strongest single argument for using it.

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 is the last thing to fail and the first thing to be blamed on something else. It surfaces on the first write after cutover, 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.

ClaimHow it was checkedResult
Admin create accepts an existing bcrypt hashPOST /auth/v1/admin/users with the source’s encrypted_password as password_hashMeasured - HTTP 200, and the user logs in with the original password
That result is attributable to password_hashControl: same create with no password material, then loginMeasured - 400 invalid_credentials
A supplied id is honouredCompared the created user’s uuid with the source’sMeasured - identical, so stored user_id values still resolve
app_metadata at creation reaches the tokenDecoded the access token after loginMeasured - tenant_id present
Duplicate address on the admin endpointSecond create with an existing addressMeasured - 422 email_exists
The admin endpoint normalises caseCreate with an upper-case variant of an existing addressMeasured - 422 email_exists, one row for that person
The SQL path does notCopied the same address upper-cased into auth.usersMeasured - accepted, two rows differing only by case
Which of the two a login reaches5 login attempts per casing, comparing the sub claimMeasured - both reachable from both inputs, mapping unstable across attempts
One duplicate costs the whole customerBulk-copied a source holding one conflicting and one clean userMeasured - 23505, 0 of 2 landed
auth.users copies many-to-oneCatalog-enumerated column copy via json_populate_recordsetMeasured - 3/3 rows over 34 non-generated columns
Password and uuid survive the SQL path tooLogin at the target, uuid comparisonMeasured - both hold
Identity rows are not needed for password loginCounted auth.identities on the target after a users-only copyMeasured - 0 rows, login still succeeds
The copy is non-destructiveLogged in at the source afterwardsMeasured - HTTP 200
Both sources allocate the same surrogate keysRead primary keys on two independently seeded sourcesMeasured - {1,2,3} on each
A single-column id primary key refuses the second sourceMerged both into itMeasured - 23505 on the primary key
primary key (tenant_id, id) keeps ids intactMerged both into itMeasured - 6 rows over 3 distinct ids
uuid keys merge with no handlingMerged a uuid-keyed table from both sourcesMeasured - 4 rows, no collision
The first write after the merge collidesInserted a new row without resyncing the sequenceMeasured - 23505; setval fixes it
RLS with no policy denies the tenantRead as a tenant between the enable and the policyMeasured - HTTP 200, 0 rows
The query endpoint is not constrained by RLSSame read via the Management APIMeasured - role postgres, all rows
Tenant isolation on merged rowsRead as each tenant, and cross-tenant with an explicit filterMeasured - 2 of 3, and []
FOR ALL with only using governs writesForged insert with return=minimal, then countedMeasured - refused, 0 rows landed
with check (true) is the write holeSame insert under a permissive checkMeasured - accepted, 1 row landed, reads still isolated
return=representation masks thatIdentical insert under the default preferenceMeasured - 403 42501 on a permitted write
A table without RLS is readable by every tenantRead a non-RLS table as the other tenantMeasured - all rows
Social and SSO identity migrationNot exercised - the run was email and password onlyNot tested
Wall-clock cost at real customer countsTwo sources, three users and three orders eachNot tested
Storage objects, Edge Functions, cron jobs, webhooksOut of scope for this runNot tested

All measurements were taken on throwaway projects created and destroyed for this guide, on 2026-08-04.