One database, and no tenant can reach another.
Multi-tenancy fails the day one customer sees another's data, and it usually fails because isolation was a WHERE clause someone forgot. We push it down into the database with row-level security, so every read and write is scoped to the tenant on the connection, automatically. A forgotten filter, a crafted request, an injection: none of them reach across, because the policy runs under every query, not inside it.
Drive it: switch tenants and run the same query, then try to read or insert as another tenant and watch the policy filter it out. The query never carries a tenant filter, and it never needs to.
Isolation under every query, not in it.
What you just operated is this. The tenant is fixed on the connection at the start, and from there the database enforces it. The application code stays simple and can't leak, because the filter it would have to remember lives in the policy instead.
The request proves who it is and which tenant it belongs to, before it touches data.
The tenant is set once on the connection, so it's ambient for everything that follows.
The app writes plain queries with no tenant filter. There's nothing for a developer to forget.
Postgres applies the USING and WITH CHECK policy under every read and write, automatically.
You get your rows, only your rows. A crafted or forgotten filter can't reach across.
The whole rule, in one place.
This is the entire isolation guarantee. One policy, attached to the table, that the database applies to every statement. The USING clause scopes reads; the WITH CHECK clause stops a write from claiming another tenant. There is no per-query filter to forget, and no code path around it.
1-- policy.sql — isolation the database enforces, not the app2ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;34CREATE POLICY tenant_isolation ON invoices5 USING (tenant_id = current_setting('app.tenant')::uuid) -- reads6 WITH CHECK (tenant_id = current_setting('app.tenant')::uuid); -- writes78-- every connection sets its tenant once, up front:9-- SET app.tenant = '...';10-- and no query on it can escape that line.The floor. Every table with tenant data carries a policy, on by default.
Set once per request from a verified token, never taken from the query body.
For the strictest data, each tenant's rows encrypted under a key only they unlock.
Every cross-tenant attempt is logged, so a probe shows up instead of passing silently.
Why this is the version that holds.
Filtering in the application works until the one query that forgets. Pushing it into the database removes the class of bug entirely, and these are the properties that come with it.
The tenant scope is a policy, not a WHERE clause, so a new endpoint is isolated the moment it's written.
WITH CHECK verifies the tenant on every insert and update, so a crafted tenant_id is rejected outright.
Even a query that reaches the database with an attacker's WHERE still runs under the policy, and sees nothing extra.
No tenant plumbing threaded through every model and query, which is exactly where the leaks usually hide.
Isolation you can point an auditor at: one policy, enabled on the table, tested against cross-tenant reads.
Per-tenant limits and pooling on top, so one heavy tenant can't starve the rest of the same database.
The cross-tenant leak is never the query you wrote carefully. It's the one you forgot.
Filtering by tenant in every query works right up until the new report, the admin tool, the migration script, or the third-party integration that doesn't. And you only find out when a customer sees a number that isn't theirs. Pushing the rule into the database doesn't make isolation fancier, it makes forgetting it impossible, because there is no longer a filter to forget. That is the entire point, and it is why we build it at the row level.
Tenant isolation you can prove, not just promise.
Row-level security enforced by the database under every read and write, WITH CHECK on writes, and defense in depth on top, so no tenant can reach another.