Skip to content
5 min read

Multi-tenant data isolation without the leak

One tenant seeing another's records is the bug you cannot apologise your way out of. Shared tables, schemas or databases — and how to make the choice enforceable.

On this page
  1. Three models, honestly compared
  2. Make the filter structural
  3. The paths that are usually forgotten
  4. Test it like a security property
  5. The question worth asking instead

Most bugs cost you a patch and an apology. This one costs you the customer, and possibly their lawyer: a list endpoint returns twenty records and three of them belong to somebody else.

What makes it dangerous is that the usual defence is a convention. Every query includes WHERE tenant_id = ?, and every developer remembers, every time, forever. That holds right up until the afternoon someone writes a reporting query at speed.

Three models, honestly compared

Model Isolation Operational cost Suits
Shared tables, tenant column Weakest — one missing clause away Lowest: one schema, one pool Most SaaS products
Schema per tenant Strong — separate namespaces Migrations run per tenant Tens to low hundreds of tenants
Database per tenant Strongest — separate everything Highest: connections, backups, deploys Compliance, few large customers

The honest default is shared tables. Costs do not grow per customer, one migration covers everyone, and the thing people fear about it — the leak — is fixable in the database rather than by discipline.

The honest exception is a contractual one. "Our data must be physically separate" is a requirement you cannot satisfy with a column, and discovering that after signing is how a quarter disappears.

Make the filter structural

If you choose shared tables, the single most valuable thing you can do is make the tenant filter something other than a thing to remember.

Row-level security puts it in the database:

sql
ALTER TABLE lead ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON lead
  USING (tenant_id = current_setting('app.tenant_id')::bigint);

Then set the tenant once, where the request is identified:

ts
await db.execute(sql`SET LOCAL app.tenant_id = ${tenantId}`);
// every query on this transaction is now scoped, whether it says so or not

The property this buys is the one that matters: a forgotten filter fails closed. The careless query returns nothing instead of everything, and an empty list gets noticed in development rather than in somebody else's account.

Two cautions. The policy does not apply to the table owner or to superusers, so the application must connect as a role that is subject to it — the usual way this is silently disabled is connecting as the owner in production. And SET LOCAL is scoped to the transaction, so a pooled connection that runs queries outside one is not covered.

If row-level security is not available, make the data layer the only door:

src/db/repo.tsts
export function repoFor(tenantId: number) {
  return {
    leads: {
      list: (limit = 20) =>
        db.select().from(lead).where(eq(lead.tenantId, tenantId)).limit(limit),
      byId: (id: number) =>
        db.select().from(lead).where(and(eq(lead.id, id), eq(lead.tenantId, tenantId))),
    },
  };
}

Nothing outside this module imports the client. The tenant is a constructor argument, not a parameter someone can omit, and a code review question becomes a lint rule.

Note byId filters on tenant too. Fetching by primary key feels safe because ids are unique — but an id from a guessed URL is still an id, and without the second predicate it reads another tenant's row perfectly.

The paths that are usually forgotten

The request path gets scoped early, because that is where the thinking happens. These four do not.

Background jobs are first. A worker processing a queue has no request and therefore no tenant, unless the job payload carries one and the worker explicitly sets it. Nothing about the code looks wrong.

Admin and support tooling is legitimately cross-tenant, which makes it the place cross-tenant queries live, and therefore the place they get copied from when someone needs something similar in a hurry.

Exports and reports get written fast, often by whoever needed the number, frequently as raw SQL that never passes through the repository at all.

And caches, which are the worst of the four because no database policy can catch them. A cache key without a tenant in it will eventually serve one tenant's data to another, and the query never ran, so row-level security never saw it and your isolation test never covered it.

That last one deserves attention in proportion to how invisible it is. Every key gets the tenant in it, no exceptions, and nobody gets to argue that this particular cache is fine. If the payloads are large, size them before they surprise you, though compressing a cache only matters once the keys are correct.

Test it like a security property

Isolation is not something you verify by reading code, because reading code is how it got written. Write the test that tries to break it.

ts
it("never returns another tenant's lead", async () => {
  const a = await createLead({ tenantId: 1 });
  const b = await createLead({ tenantId: 2 });

  const asTenantOne = repoFor(1);
  expect(await asTenantOne.byId(b.id)).toHaveLength(0);   // not 404-by-accident — actually empty
  expect(await asTenantOne.list()).toEqual([expect.objectContaining({ id: a.id })]);
});

Run it against every repository method and add one for each new table. It is the rare test that pays for itself the first time somebody adds a query in a hurry, which will be sooner than you expect.

The question worth asking instead

Tenancy is an early decision because it is expensive to reverse and cheap to get right at the start. Pick shared tables unless a contract says otherwise, then make the tenant filter structural, through row-level security or a repository nobody can bypass.

And stop asking whether you remembered the filter. Ask what happens when somebody forgets.

If the answer is an empty result, you have isolation. If the answer is somebody else's data, you have a convention, and every convention has a failure rate you are not measuring.

Frequently asked

What is the best multi-tenant database design?

Shared tables with a tenant id suit most SaaS products: one schema to migrate, one connection pool, and costs that do not grow per customer. Schema-per-tenant buys stronger isolation and per-tenant restores at the price of running migrations many times. Database-per-tenant is the strongest isolation and usually reserved for compliance requirements or a small number of large customers.

How do I stop one tenant seeing another tenant's data?

Do not rely on every query including a WHERE clause. Enforce it below the application: Postgres row-level security with a session variable for the current tenant, or a repository layer that is the only way to reach the database and always applies the filter. The property you want is that a forgotten filter returns nothing rather than everything.

What is row-level security in Postgres?

A policy attached to a table that the database applies to every query automatically. You set the current tenant on the connection, and the policy restricts visible rows to that tenant regardless of what the query says. A developer who forgets the WHERE clause gets an empty result instead of someone else's records.

Can you migrate from shared tables to separate databases later?

Yes, and it is expensive. Every query, every background job, every report and every connection path assumes a shape, and moving tenants out means moving data while the system is live. Treat the tenancy model as an early architectural decision rather than something to revisit when the first large customer asks.

Found this useful?Share on XShare on LinkedIn

Related reading