← Writeups

Multi-Tenant Isolation the Database Enforces

May 30, 2026 · 3 min read

In a multi-tenant system, the scariest bug isn't a crash. It's a query that quietly returns the wrong tenant's rows. It doesn't error, it doesn't page anyone, and you might not find out until a customer does.

The problem

The usual defense is discipline: every query filters by tenant, and code review catches the ones that don't. That works until it doesn't. There are hundreds of queries, they get written under deadline, and a single missing WHERE tenant_id = ? is enough. Discipline is a control you have to apply correctly every single time, forever — which is another way of saying it will eventually fail.

The assumption baked into "just remember to filter" is that every current and future query, written by every current and future engineer, gets it right. That's not an invariant. That's hope with extra steps.

The solution

Push the rule down to the one layer that sees every query and can't be talked out of it: the database. If a tenant scope is set, every row read or written matches it — enforced by the database, regardless of what the query says.

Make the floor refuse, don't make the app remember

With row-level security policies on the tenant-scoped tables, the database itself filters by the current tenant. A query that forgets its WHERE clause doesn't leak — it simply returns nothing it isn't allowed to. The application can still be wrong; it just can't be wrong in this particular catastrophic way. Correctness moved out of discipline and into the shape of the system.

Two layers, on purpose

This doesn't replace application-level authorization — it sits under it. The app still decides who can be where; the database decides what they can see once they're there. The redundancy is the entire point: defense in depth means a single failure on either layer is caught by the other. The app validates the request; the floor refuses anything the app let through by mistake.

Carry scope as context, not as a parameter

For the database to enforce the rule, the current tenant has to be set on the connection for every unit of work — web request, background job, scheduled task. So tenant identity rides in request-scoped context and gets applied at each entry boundary, rather than being threaded through every function signature where it can be dropped. Identity is context; the things that vary within a request stay arguments.

Separate the role that's allowed to bypass

Migrations and admin tooling legitimately need to cross tenants. Those run as a different database role that can bypass the policy; the application runs as a role that can't, by construction. The ability to see everything is a capability you grant deliberately, not a default everyone inherits.

What it cost

There's real upfront work: every tenant-scoped table needs the policy, every entry point needs to set the scope, and you roll it out carefully — lowest-blast-radius tables first, validated on a throwaway before production. The payoff is that the worst-case bug in a multi-tenant system stops being a thing you can ship by forgetting a clause. The invariant holds even when the code is wrong, which is exactly when you need it to.