Postgres best practices
Multi-tenant RLS in Supabase: keep each team's data apart
Give every tenant-owned table a `tenant_id`, keep a `memberships` table that maps users to tenants, and write every policy as "the row's tenant is one of my tenants". Then test it: sign in as a user from tenant A and prove that every query for tenant B returns nothing.
Last updated
What does the schema look like?
create table public.tenants (
id uuid primary key default gen_random_uuid(),
name text not null
);
create table public.memberships (
tenant_id uuid not null references public.tenants (id) on delete cascade,
user_id uuid not null references auth.users (id) on delete cascade,
role text not null check (role in ('owner', 'member')),
primary key (tenant_id, user_id)
);
create index memberships_user_idx on public.memberships (user_id);
create table public.projects (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants (id) on delete cascade,
name text not null
);
create index projects_tenant_idx on public.projects (tenant_id);How do I write the tenant policy?
The `with check` matters: without it, a user could insert a row into a tenant they do not belong to.
alter table public.projects enable row level security;
create policy "projects: members read" on public.projects
for select to authenticated
using (tenant_id in (
select tenant_id from public.memberships where user_id = (select auth.uid())
));
create policy "projects: members write" on public.projects
for insert to authenticated
with check (tenant_id in (
select tenant_id from public.memberships where user_id = (select auth.uid())
));Where do cross-tenant leaks come from?
- A new table added without `tenant_id` or without a policy.
- A view that runs as its owner and so skips RLS (see the security definer guide).
- An RPC function marked `security definer` that takes a tenant ID as a parameter and never checks membership.
- Tenant IDs read from `user_metadata`, which the user can change.
How do I prove tenants are isolated?
Write a test that creates two tenants with one user each, signs in as user A and tries to select, update and delete tenant B's rows through the API. Every attempt must return zero rows. Run it in CI, and add every new table to it. A test that lists tables from `pg_class` and fails when a table lacks `tenant_id` or RLS catches the most common regression.
Frequently asked questions
- Should I put the tenant ID in the JWT?
- You can put it in `app_metadata` through a custom access token hook, which saves a lookup. Never put it in `user_metadata`, because users can edit that.
- One schema per tenant or tenant_id columns?
- For most SaaS apps, a `tenant_id` column with RLS is simpler to migrate and query. Schema-per-tenant makes sense only for strong isolation needs and a small number of large tenants.
- How does Supahound help with multi-tenant apps?
- Supahound reads your RLS policies to learn who owns which rows, then watches the logs for one actor reading rows from many owners. That is the traffic pattern of a cross-tenant leak.