Multitenancy without leaking
The security questionnaire always has a version of this row: "Describe how one customer's data is isolated from another's." It's a paragraph in a spreadsheet. Behind that paragraph is one of the hardest, least-forgiving disciplines in enterprise SaaS, because getting it wrong once is the kind of incident a company doesn't always survive.
Multitenancy is the deal struck to make SaaS economics work: one codebase, one running system, many customers sharing it. The upside is obvious — the team maintains one thing, not a thousand. The catch is a rule with no tolerance band: tenant A must never, under any circumstance, see tenant B's data. Not in a list. Not in a search result. Not in an export, a webhook, a cached fragment, an error message, or an analytics rollup. Every other bug degrades the product. This one ends accounts and triggers breach notifications.
The lesson underneath is that this isn't achieved by being careful. It's achieved by making the system enforce it, at a layer below where any individual engineer can forget.
The isolation spectrum
There isn't one way to be multi-tenant; there's a spectrum, and where a system sits is a trade between blast radius and economics. The industry names the three points silo, bridge, and pool.
Silo — a full database (or account, or cluster) per tenant. Isolation is physical: no query could return another tenant's data because it isn't in the database being connected to. Beautiful, and it's what a nervous enterprise buyer wants to hear. It's also expensive, operationally heavy, and it fights back the moment there are thousands of tenants or a cross-tenant analytic is needed.
Pool — everyone shares the same tables, and a tenant_id column on every row is the only thing separating one customer from the next. This is where most SaaS actually lives, because the economics are unbeatable: one schema, one connection pool, one migration. The price is that isolation is now a property of the code, not the infrastructure. Every query that forgets to filter by tenant is a potential breach.
Bridge sits between — one database, a schema per tenant — and mostly inherits the awkwardness of both neighbors. Real systems often end up mixed: pooled by default, with the largest or most sensitive customers siloed onto their own infrastructure as a premium tier.
The leak is silent
Here's what makes the pooled model dangerous in a way that most bugs aren't: a tenant-isolation failure throws no error. The query runs. The page renders. The API returns 200. It's just that some of the rows belong to someone else.
-- the bug is not what's here. it's what's missing.
SELECT * FROM invoices WHERE status = 'overdue';
-- ↑ no tenant_id. every customer's overdue invoices, to whoever asked.
No stack trace catches that. No type checker objects. It ships, it works in the demo (dev only has one tenant), and it sits quietly in production until the day a customer sees a competitor's invoice in their list and the trust built over years evaporates in one screenshot. This is broken access control — consistently at the top of the OWASP list — wearing the specific, brutal costume of multitenancy.
Every other class of bug announces itself. A tenant leak is the one that runs perfectly, returns 200, and hands the wrong customer the wrong data with no error anywhere to raise a flag.
A silent failure can't be defended against by asking engineers to remember a WHERE clause on every one of ten thousand queries, forever, including the intern's, including the one written at 2am during an incident. Human vigilance is exactly the wrong tool. The defense has to be structural.
Push isolation below the query
The principle that actually works: no single query should be trusted to enforce isolation, because any single query can be wrong. Put the enforcement underneath the query, in layers, so a forgotten filter is caught by the layer below it.
The layer that changes the game is the database itself. In Postgres, Row-Level Security attaches a policy to a table that filters every query against a tenant set on the session — regardless of what the query said:
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON invoices
USING (tenant_id = current_setting('app.current_tenant')::uuid);
With that policy live and the tenant pinned to the session at the request boundary, the buggy query from §02 — the one that forgot tenant_id — returns only the current tenant's overdue invoices anyway. The database refused to leak. The application-layer scope is still worth having for convenience and clarity, but it's no longer the thing standing between the system and a breach. That's the whole idea: demote human diligence from load-bearing to nice-to-have.
Data isn't the only thing that leaks
Isolation is usually taught as a data problem. In production it's three problems, and the other two bite once a system is at scale.
- DATA
- The one everyone means — no tenant reads another's rows. Handled by §03's layers.
- PERFORMANCE
- The noisy neighbor. One tenant's runaway report or bulk import starves everyone else's queries. Needs per-tenant rate limits, quotas, and sometimes a dedicated pool for the whales.
- FAILURE
- Blast radius. When something breaks, does it break for one tenant or all of them? Pooled everything means a bad migration or a poison record can take down the whole book at once.
The enterprise buyer's questionnaire asks about the first. The 2am page is usually about the second or the third. A mature multi-tenant system has an answer for all three.
What to wire in
Same instinct as the rest of this series: make the platform enforce the invariant so no single line of code can betray it.
- 01
Row-level security as the backstop
RLS on every tenant-scoped table, with the tenant set on the session at the request boundary. Non-negotiable when pooled — it's the layer that makes a forgotten filter a no-op instead of a breach.
- 02
One tenant-context chokepoint
A single middleware that resolves the tenant from the request and pins it to the DB session — and no other way to open a tenant-scoped connection. One place to get right, one place to audit.
- 03
An isolation test suite
Automated tests that seed two tenants and assert, table by table, that A can never read or write B. Run every build. This is the artifact that answers the questionnaire's isolation row with evidence, not adjectives.
- 04
Per-tenant quotas & blast-radius limits
Rate limits and resource caps per tenant so one can't starve the rest, plus a story for isolating the largest tenants when pooled economics stop paying off. Performance and failure isolation, not just data.
Tenants aren't kept apart by being careful ten thousand times. They're kept apart by building one system that can't hand over the wrong data even when the query is wrong.
Corrections welcome
Tenant isolation is the discipline in this series least forgiving of a mistake, and the one where the real knowledge is all in the scars. So those are the corrections most worth having: the isolation bug that got past the defenses and how it got there, the place RLS bit back in production, the point where pooled economics broke and siloing had to start. The failure modes are where the knowledge lives, and they don't make it into the questionnaire answer.
Email or X. Building in the open is partly a learning project; the outside input is the point.
— Neil
