Tenant isolation belongs in the database, not the app
In a shared database, one missed WHERE clause is a cross-customer leak. So the rule lives somewhere a developer can’t forget it: Postgres itself refuses to hand back another tenant’s row.
Nearly every piece of business software you pay for is multi-tenant. Your records and some other company’s records live in the same database, a few rows apart, kept separate by nothing you can see. That arrangement is fine until it isn’t. Multi-tenant software gets exactly one failure it can never have. Everything else is a bug you fix on a Tuesday. Showing one customer another customer’s data is the bug.
Which makes the question underneath the entire architecture a simple one. Where does isolation actually live?
The filter you will eventually forget
The usual answer is to put WHERE org_id = ? on every query and trust the team to keep doing it. That holds until the day someone doesn’t. A working codebase has hundreds of reads and writes in it, added by different people in different months under different deadlines, and the odds that every one of them carries the filter forever are not odds anybody should bet a business on. Each new query is a fresh chance to leak, and what leaks is another company’s customers onto your screen.
The filter also has to survive conditions nobody is thinking about while writing a handler. A database connection gets reused by the next request. A background job runs on behalf of no particular user. A helper three layers below the handler assembles part of the query and has no idea whose data it’s about to touch.
Isolation you have to remember is isolation you will eventually forget.
Make Postgres do the refusing
So the rule doesn’t live in code a person writes query by query. It lives in the database. Every table holding tenant data runs Postgres FORCE ROW LEVEL SECURITY, and every request opens a transaction that names the organization it’s acting for before it touches a single row:
BEGIN;
SET LOCAL app.current_org = 'org_abc123';
-- every SELECT/INSERT/UPDATE/DELETE from here is
-- filtered by the RLS policy, automatically
COMMIT;From there the policy does the work. It checks app.current_org against the row’s organization on every read and every write. If a query forgets its filter, the database still refuses the other tenant’s row. If the connection is handed straight to the next request, the SET LOCAL died with the transaction, so nothing bleeds across. The guarantee no longer rests on the application being perfect. It rests on Postgres behaving like Postgres.
What that buys
- A missed
org_idfilter returns nothing instead of returning somebody else’s records. - An application bug can’t become a cross-customer leak, because the enforcement sits one layer below the bug.
- The rule is written in one place, so it can be read in full and reviewed in a sitting, rather than audited across every query in the codebase.
Row-level security is the floor. The most sensitive records are held server-side, public links are scoped to a single action, and higher-risk AI work waits behind a review gate. Every one of those is built on the assumption that the layer beneath it is holding.
If you’re evaluating software that will hold your client list, that’s the question worth putting to whoever built it. Is isolation something their engineers remember, or something their database enforces?