# 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.

Source: https://supahost.app/kb/multi-tenant · Updated 2026-11-11

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:

```sql
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):

```sql
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.

## Related

- [Row Level Security](/docs/database/row-level-security)
- [RLS performance best practices](/kb/rls-performance)
- [infinite recursion detected in policy](/kb/infinite-recursion-policy)
- [How many projects can I run on one instance?](/kb/how-many-projects)
