burneh
Infrastructure

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.

postgres · tenant session
logged in as
session
SET app.tenant = 'acme';
query
SELECT * FROM invoices;
— Postgres applies: USING (tenant_id = app.tenant)
result3 of 8 rows visible
idcustomeramount
INV-1001Northwind$4,200
INV-1002Contoso$1,850
INV-1003Fabrikam$3,100
3 rows returned · scoped to Acme

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.

request+ JWTauthenticateuser + tenantset contextapp.tenantqueryno filterRLS policyUSING / CHECKscoped rowsyour tenant0 rowsothers filteredyoursforged id0102030405
01
Authenticate

The request proves who it is and which tenant it belongs to, before it touches data.

02
Set context

The tenant is set once on the connection, so it's ambient for everything that follows.

03
Query

The app writes plain queries with no tenant filter. There's nothing for a developer to forget.

04
Policy

Postgres applies the USING and WITH CHECK policy under every read and write, automatically.

05
Scoped

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.

policy.sql
1-- policy.sql — isolation the database enforces, not the app
2ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
3
4CREATE POLICY tenant_isolation ON invoices
5 USING (tenant_id = current_setting('app.tenant')::uuid) -- reads
6 WITH CHECK (tenant_id = current_setting('app.tenant')::uuid); -- writes
7
8-- every connection sets its tenant once, up front:
9-- SET app.tenant = '...';
10-- and no query on it can escape that line.
defense in depth
Row-level security

The floor. Every table with tenant data carries a policy, on by default.

Connection-scoped tenant

Set once per request from a verified token, never taken from the query body.

Per-tenant keys, optional

For the strictest data, each tenant's rows encrypted under a key only they unlock.

Audited access

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.

No query can forget the filter

The tenant scope is a policy, not a WHERE clause, so a new endpoint is isolated the moment it's written.

Writes can't impersonate

WITH CHECK verifies the tenant on every insert and update, so a crafted tenant_id is rejected outright.

Injection stays in its lane

Even a query that reaches the database with an attacker's WHERE still runs under the policy, and sees nothing extra.

The app code stays simple

No tenant plumbing threaded through every model and query, which is exactly where the leaks usually hide.

Provable, not hopeful

Isolation you can point an auditor at: one policy, enabled on the table, tested against cross-tenant reads.

Noisy neighbors contained

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.