The scariest bug in multi-tenant SaaS is one tenant seeing another's data. We made that class of bug fail closed with Postgres row-level security as a database backstop, so a query that forgets its organisation filter returns nothing instead of everything.
The bug we refused to ship
The system holds some of the most sensitive data a company owns: people's job applications, their CVs, private interviewer notes, rejection reasons. In a shared-schema multi-tenant system, every one of those rows carries an organisation identifier, and every query is supposed to filter on it. The word supposed is doing an enormous amount of work in that sentence.
A cross-tenant data leak is one forgotten filter away: a single query, written on a tired afternoon months from now, that omits the organisation condition and suddenly returns everyone's candidates. Application-level scoping is necessary, but it is exactly the kind of safeguard that fails silently and catastrophically, because nothing breaks until the day it leaks.
Defence in depth, deliberately
We decided early that organisation isolation was too important to trust to a single layer. The query layer would still scope explicitly by organisation, because it is fast, readable and how the application naturally expresses intent. But underneath it, the database itself would enforce isolation with Postgres row-level security.
The guiding principle was fail closed: when something goes wrong, the safe outcome should be the default outcome. A query that forgets to filter should return nothing rather than everything. Fail-closed multi-tenancy is a backstop to application scoping, not a replacement for it.
How the backstop works
Row-level security lets Postgres attach a policy to a table that silently filters every query against it. We run application traffic through a restricted database role that these policies apply to, and the policies are keyed on a per-request setting that names the current organisation. Organisation-scoped reads happen inside a transaction that sets that value first; from then on, every table the query touches is filtered to that organisation automatically, whether or not the SQL remembered to say so.
The crucial property is what happens when the context is missing. With no organisation set, the policy matches no rows. An unfiltered query in the wrong place does not return the whole table; it returns nothing. The failure is empty, not open. A policy keyed on request context is only useful if the context is reliably present, so rather than passing an organisation identifier into every database call, we hold the correctly-scoped database handle in a per-request store and route ordinary calls through it transparently whenever they are inside an organisation boundary. Application code just queries as usual; the boundary handles the plumbing.
The lines we drew on purpose
Not everything belongs to a single tenant, and forcing it to would break the system. Signing in, serving the public careers site, and running the background worker all legitimately operate across organisations or outside any single one. Those paths use a separate owner connection that bypasses row-level security by design. Drawing that line explicitly, these paths are cross-tenant on purpose and everything else is scoped, was itself part of the design rather than an oversight.
There is a further subtlety we chose deliberately and documented so it would not look like a mistake later: write operations do not run inside the row-level security transaction. Our web framework signals a post-write redirect by throwing a special control-flow value, and if that happened inside the transaction, the transaction would roll back and the write would be lost. So writes rely on explicit organisation scoping and validation, while reads get the automatic backstop. Reads are where an accidental leak is most likely and most damaging, so that is where the automatic net belongs.
Proving it, not assuming it
A security control you have not tested is a hope, not a control. So we verified the isolation directly. Under the restricted role with no organisation context, a query returns zero rows. With a specific organisation set, it returns only that organisation's rows. The owner connection used by the cross-tenant paths sees everything as intended. Fail-closed behaviour confirmed, not assumed.
The cost of all this is modest: two connection pools, a little transaction ceremony on reads, and one rule the team has to keep, that organisation reads go through the boundary. Against that we get the property we actually wanted, which is about the shape of our worst day. Without the backstop, a forgotten filter is a headline. In the world we built, the same mistake renders a page empty. The scariest bug in multi-tenant SaaS degrades from a competitor seeing your candidates to a screen looking blank until we fix it.

