How do I isolate tenant data in a multi-tenant SaaS?
Two ways on SupaHost: shared tables with a tenant_id column and Row Level Security, or one project per tenant on the same instance. How each works and when to pick it.
You have two good options. Most SaaS apps keep all tenants in shared tables with a tenant_id column and let Row Level Security filter every query to the user's tenant. If tenants need hard separation (their own database, users, keys and backups), give each one its own project; a SupaHost instance holds many projects for a flat price.
Option 1: shared tables and RLS
One project, one set of tables, a membership table linking users to tenants:
create table public.tenants (id uuid primary key default gen_random_uuid(), name text not null);
create table public.memberships (
tenant_id uuid references public.tenants (id) on delete cascade,
user_id uuid references auth.users (id) on delete cascade,
role text not null default 'member',
primary key (tenant_id, user_id)
);
create table public.invoices (
id bigint generated always as identity primary key,
tenant_id uuid not null references public.tenants (id),
total_cents int not null
);
create index on public.invoices (tenant_id);
A security definer helper returns the tenants of the current user without re-checking RLS on memberships (which would cause recursion):
create function public.my_tenants()
returns setof uuid
language sql stable security definer set search_path = ''
as $$ select tenant_id from public.memberships where user_id = (select auth.uid()) $$;
alter table public.memberships enable row level security;
alter table public.invoices enable row level security;
create policy "see own memberships" on public.memberships
for select to authenticated using (user_id = (select auth.uid()));
create policy "tenant members read invoices" on public.invoices
for select to authenticated using (tenant_id in (select public.my_tenants()));
Every supabase.from('invoices').select() now returns only rows from the caller's tenants. Index tenant_id on every tenant table so the policy stays fast.
Option 2: one project per tenant
Each project has its own Postgres database, Auth users, Storage, URL and keys, so tenants can't see each other even if a policy is wrong. You can restore one tenant's project from a backup without touching the others.
The costs: plans include 5 (Builder), 15 (Pro) or 30 (Business) projects, extra projects are $3/month each, and all projects share the instance's CPU, memory, database and file quotas. Schema changes have to be applied to every project, which the MCP apply_migration tool or a migration script can loop over. Users who belong to several tenants need an account in each.
How to choose
| Shared tables + RLS | Project per tenant | |
|---|---|---|
| Number of tenants | thousands | tens |
| Isolation | policies | separate databases |
| One user, many tenants | easy | separate accounts |
| Per-tenant restore | no (one database) | yes |
| Schema changes | once | once per project |
FAQ
Should I put tenant_id in the JWT?
You can store it in app_metadata and read it with auth.jwt(), but the membership lookup above stays correct when someone changes tenants, without waiting for a token refresh.
Can I use one Postgres schema per tenant?
It's possible in Postgres, but the Data API only exposes public, storage, graphql_public and pgmq_public, and that list can't be changed. Schema-per-tenant tables wouldn't be reachable from supabase-js.
How many tenants can shared tables handle?
As many as your data fits. With tenant_id indexed, the policy check stays cheap.