Build Daily

Tinley Park · July 25, 2026

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.

§ 01
CHAPTER

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.

The tenant isolation spectrum — silo, bridge, poolThree models left to right. Silo: a separate database per tenant, strongest isolation, highest cost. Bridge: a shared database with a schema per tenant, middle ground. Pool: shared tables with a tenant_id column, cheapest and most scalable, weakest physical isolation. An arrow shows isolation decreasing left to right while cost efficiency and scale increase.THE ISOLATION SPECTRUMstronger isolation ← → cheaper & more scalableSILOa database per tenantstrongest isolationhighest cost · hardest to scaleBRIDGEa schema per tenantshared databasethe awkward middlePOOLshared tables + tenant_idcheapest · most scalableone WHERE clause from a leakISOLATION BY INFRASTRUCTUREISOLATION BY CODEFIG. 1 — Most SaaS lives in the pool, because economics. Which means isolation is the code's job, not the infrastructure's.
↗ click to enlarge

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.

§ 02
CHAPTER

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.

§ 03
CHAPTER

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.

Defense in depth for tenant isolationFour stacked layers a request passes through. Top: application-layer scoping, the default query filter. Second: a tenant context set on the database session. Third: Postgres row-level security policies that enforce the tenant filter at the database. Fourth: an isolation test suite that asserts no cross-tenant read. The message is that even if the top layer forgets the filter, the row-level security layer below still blocks the leak.DEFENSE IN DEPTHif a layer forgets, the layer beneath it still holds the line1 · APP SCOPINGdefault tenant filter in the ORM / data layer — convenient, but forgettable2 · TENANT CONTEXTcurrent tenant pinned to the DB session at request boundary — one source of truth3 · ROW-LEVEL SECURITYthe database itself refuses to return another tenant's rows — the backstop4 · ISOLATION TESTSautomated proof that tenant A can never read tenant B — run every buildTHE BACKSTOPFIG. 2 — App scoping is convenience; row-level security is the layer that turns a forgotten WHERE from a breach into a no-op.
↗ click to enlarge

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.

§ 04
CHAPTER

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.

§ 05
CHAPTER

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.

§ 06
CHAPTER

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

  • #multitenancy
  • #tenant-isolation
  • #postgres
  • #security
  • #b2b-saas
  • #engineering
  • #field-notes
  • #shipping-for-the-enterprise

Continue reading