Security
Tenant isolation belongs in the database, not the ORM
Every multi-tenant bug we have seen has the same shape: a query that forgot its WHERE clause. Row-level security makes that failure return nothing instead of everything.
Security
The usual approach to multi-tenancy is a discipline: every query filters on organisation id, and code review catches the ones that do not. That works until it does not, and the failure mode is the worst available — one customer sees another customer's data, silently, with no error raised anywhere.
The problem is that the safety property lives in the developer's head. Nothing in the system enforces it.
Move the predicate into Postgres
Row-level security inverts the default. Every tenant table carries a policy, and the policy is evaluated by the database on every statement — not by the application, and not conditionally.
The predicate reads a session variable that the transaction sets before doing anything else. A query that forgets its tenant filter now returns zero rows, which surfaces as an obvious bug in development rather than as a breach in production.
ALTER TABLE records ENABLE ROW LEVEL SECURITY;
ALTER TABLE records FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON records
USING (organization_id = current_setting('app.current_org', true)::uuid)
WITH CHECK (organization_id = current_setting('app.current_org', true)::uuid);FORCE is not optional
`ENABLE ROW LEVEL SECURITY` alone exempts the table owner. If your application connects as the role that owns the tables — which is the default in most setups — every policy you just wrote is inert.
`FORCE ROW LEVEL SECURITY` closes that, and the application must connect as a separate, non-owning, non-superuser role. This is the step most implementations miss, and it is the one that decides whether any of the rest matters.
The application side is three lines
Everything above is inert unless the session variable is set, so the tenant context is established once, inside the transaction, and every query within it is covered automatically.
export async function withTenant<T>(organizationId: string, fn: (tx: Tx) => Promise<T>) {
return db.transaction(async (tx) => {
await tx.execute(sql`SELECT set_config('app.current_org', ${organizationId}, true)`);
return fn(tx); // a forgotten WHERE here returns nothing, not everything
});
}What it costs
Policies are evaluated per row, so they are not free — expect single-digit percent overhead on large scans, and make sure organisation id is the leading column of your indexes.
That is a real cost. It buys a property the alternative cannot offer at any price: the safe behaviour is the default, and the unsafe one requires deliberate effort.
