Isolation is a ladder, and every rung costs more.
App checks, row-level security, schema, database, account, cell. Each rung up turns a forgotten filter into a rarer, different mistake.
Change one digit in the URL, see another tenant’s invoice.
The handler ran WHERE id = 88232. The token was valid; only the object was wrong. Add AND tenant_id = :tid from the token, and return 404.
14 days
of every tenant’s invoices, per day of scraping
60,000 invoices a day across 20,000 businesses. A script at 10 requests a second walks 864,000 sequential IDs a day.
Where does the tenant ID come from?
Bad: An X-Tenant-ID header
The client can set it to anyone.
Good: The verified token
A header may only select among tenants the user belongs to.
RLS is on. A sets its tenant on a pooled connection; B reuses it without a SET.
The trap
A plain SET lasts the session, so B gets A’s rows. Use SET LOCAL in a transaction.
Most leaks come through side doors nobody thought were doors.
Cache keys missing the tenant, vector search filtered after top-k, exports that skip RLS, jobs that lose context, and the support console.
Pool the long tail; give the bank its own cell.
Small tenants share one pool with RLS. Mid-market gets a database each. A bank gets a dedicated cell in India, with its own keys.
Prove isolation before a bank asks
- Two-tenant tests: B’s token, A’s IDs, expect 404
- CI fails any tenant table without RLS
- Test through the real role and pool
- Canary tenants whose markers must never appear
Remember
Derive the tenant from the verified token, carry it everywhere and enforce it twice at every data access. Close the side doors, silo tenants who need it, and prove it with two-tenant tests.