TENANT ISOLATIONKeep customer data apartYour SaaS serves many customer companies, and each company, or tenant, must only ever see its own records and files. A leak between tenants is not an ordinary bug: it is the kind of incident that ends contracts. One rule, set before the first feature, shapes the whole data layer: every piece of tenant data belongs to exactly one tenant, and nothing reads or writes it without saying which tenant is asking.
Shared database, schema per tenant or database per tenant?
For most new SaaS products we start with one shared database and a tenant_id column on every tenant-owned table. It is the simplest to run, but isolation then depends on your code and your database policies.
- Shared tables with a tenant id: the cheapest to run and change. Isolation comes from query scoping plus row-level security.
- Schema per tenant: each customer gets its own tables in one database. Every migration runs once per tenant, and Postgres schemas are not rigidly separated: a role with privileges on several schemas can read across them.
- Database per tenant: the strongest separation, at the cost of more databases to migrate, back up and monitor. Keep it for the customer whose contract demands it.
How do you scope every query to the right tenant?
Put the tenant filter in one place every query passes through, instead of trusting each developer to remember it. Work out the tenant once per request on the server, from the signed-in user's membership and never from an id the browser sends, then pass it to a data-access layer that filters every read and stamps every write. With Prisma, a Client extension can hook into each query. Leaks come from the one query where someone forgot the filter; a central layer makes that mistake much harder to write.
Make unique constraints tenant-aware too, such as tenant_id plus invoice number: Postgres checks unique and foreign key constraints without applying row security, so a global rule can reveal that a value exists in another tenant.
What does row-level security add?
Row-level security (RLS) is a second lock inside the database, so a missed filter in the app does not become a leak. Once RLS is enabled on a Postgres table, every normal read and write must be allowed by a policy, and with no policy at all Postgres applies a default deny: nothing is visible.
alter table invoices enable row level security;
create policy tenant_isolation on invoices
using (tenant_id = current_setting('app.tenant_id')::uuid);
-- per request, inside the transaction
select set_config('app.tenant_id', $1, true);The final true keeps the setting local to the transaction. Because the policy has only a USING clause, Postgres applies the same rule to new and updated rows, so nobody can write into another tenant either. Then check the role your app connects as: superusers and roles with BYPASSRLS skip policies, and so does the table owner unless you set FORCE ROW LEVEL SECURITY.
How does this work on Supabase?
Supabase builds on the same feature and says to enable RLS on every table in an exposed schema, because without it any role with a grant can read and write that table. With RLS on and no policies yet, nothing is reachable through the API with the publishable key.
- The publishable key is for the browser and only reaches what your policies allow. A secret key, like the older
service_rolekey, bypasses every policy and stays on the server. - In Next.js, never give a secret a
NEXT_PUBLIC_name: those values are inlined into the JavaScript sent to the browser. - The
postgresrole hasbypassrlson Supabase, so an ORM connected as that role is not protected by your policies. - Views bypass RLS by default. On Postgres 15 and above, create them with
security_invoker = true.
What about files and background jobs?
Both need the same scoping. Store each tenant's uploads under a folder named by its tenant id: Supabase Storage allows no uploads without RLS policies on storage.objects, and its storage.foldername() helper lets a policy check the folder. Queued jobs and webhooks often run with server credentials that skip RLS, so each job should carry its tenant id and use the same scoped data layer. Exports and caches should carry it too.
How do you prove and monitor isolation?
Write tests that deliberately try to cross tenants, and run them on every change. Sign in as a user of one tenant and try to read, update and delete another tenant's records, through your routes and directly in the database. A row hidden by a policy usually returns zero rows rather than an error, so assert on what came back. On Supabase, pgTAP tests run with supabase test db, and the docs include a worked multi-tenant example.
Then keep a record of who accessed what. Log the tenant, user, action and record for exports, deletions, permission changes and staff access to customer data. Admin tools are the one place that legitimately crosses tenants, so give them their own audit trail. For logging inside the database, Supabase supports the PGAudit extension.
Where to start
List every table, bucket and job, and mark which tenant owns each. Then add the tenant id, the scoping layer, RLS and one cross-tenant test, in that order. Our web app and SaaS development service plans tenant isolation from the first migration, and the boring stack behind our own SaaS shows the pieces we build with. For a second opinion on your data model, get in touch.
FAQ
Frequently asked questions
Is row-level security enough on its own?
No. RLS is the second lock, not the only one. A policy can be wrong, a background job may run with credentials that skip it, and a view or function can sidestep it if set up carelessly. Keep tenant scoping in the app's data layer as well, so a mistake in one layer is caught by the other, and test both.
Can one user belong to more than one tenant?
Yes, and it is common for agencies, accountants and consultants. Model it with a membership table that links a user, a tenant and a role, rather than a single tenant id on the user. Supabase's docs note that a membership table keyed on team and user does not index the user column on its own, so add that index for your policies.
Should the tenant id come from the login token?
It can, for speed, but a token is only as current as its last refresh. Supabase notes that a change to a user's app_metadata does not show in auth.jwt() until the token is refreshed. For anything that must take effect immediately, such as removing someone from a team, check a membership table in the database instead.
Does row-level security slow the database down?
It can if the policies are careless. Postgres evaluates the policy against each candidate row, so an unindexed filter column turns a read into a sequential scan. Make the tenant column lead a btree index, and keep policies to simple comparisons on the row itself, which the Postgres docs describe as the simplest and best-performing case.
Can we move a large customer to its own database later?
Yes, if every tenant-owned row has carried a tenant id from day one, because that customer's data can then be copied out by that id and checked. Adding the id to an existing product later is the expensive part, so it belongs in the first migration even if every customer shares one database for years.
What should we do if a secret key leaks?
Treat it as exposure of all your data, because a Supabase secret key bypasses every RLS policy. Fix the cause of the leak, then rotate the key straight away. Supabase recommends a separate secret key for each backend component, so a leak from one service means rotating only that one key.

