Multi-tenant data isolation is the set of guarantees that keep one tenant's data from being read, written, or inferred by another tenant sharing the same application and infrastructure. This guide is about the five or six places tenant context has to travel that nobody wrote a filter for, because nobody framed them as the same problem.
Ask five engineers where a cross-tenant leak would happen in their system and four will point at the main database query - the one place they already know to defend. That's usually the one place that's fine. The database was never touched or wrong in most real incidents; the boundary that failed was somewhere the request's tenant context never traveled to begin with.
None of the seven principles below are really about the database. They're about the fact that a tenant boundary correctly enforced in one system says nothing about whether it's enforced in the five or six systems around it - caches, queues, search indexes, object storage - that were never designed to inherit it.
A request handler reads tenant_id from the JSON body, or from an X-Tenant-Id header, because that's what the client sent and the alternative felt like extra plumbing. It works in every test, because the test client always sends its own tenant ID. It fails the day a caller sends a different one, because nothing on the server side ever checked that the value belonged to the authenticated session.
const tenantId = req.body.tenantId ?? req.headers['x-tenant-id'];
const { tenantId } = req.auth.claims; // set by JWT verification middleware, not by the clientThis is the failure OWASP catalogs as Broken Object Level Authorization: the API trusts an identifier the caller supplied instead of verifying it server-side. A tenant field on a create or update payload must never bind through mass assignment either — set it from context, not from the request.
A line_items table has no tenant_id column because it's always reached through orders, and orders is correctly scoped. This is fine until someone writes a report query that joins line_items to products directly, skipping orders entirely. Nothing breaks. Nothing alerts. The query just quietly returns every tenant's line items.
CREATE TABLE line_items ( id BIGINT PRIMARY KEY, order_id BIGINT REFERENCES orders(id) );
CREATE TABLE line_items ( id BIGINT PRIMARY KEY, tenant_id UUID NOT NULL, order_id BIGINT REFERENCES orders(id) ); CREATE INDEX idx_line_items_tenant ON line_items(tenant_id);
This duplicates the tenant ID across every table that references it - denormalization costs something in write complexity, but less than a table that can't be independently constrained. Related trap: don't fold the tenant ID into a composite key in application code.
The standard is “every query includes WHERE tenant_id = ?,” and for a while that's literally true - until the codebase has four hundred call sites and one, in a rarely-touched export feature, was written before the standard existed. A per-call-site rule doesn't fail all at once. It fails one call site at a time, silently.
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON invoices
USING (tenant_id = current_setting('app.current_tenant')::uuid);PostgreSQL's row security policies are one mechanism for this; an ORM-level global scope or a repository base class works the same way at the application layer. What matters is that a new query can't omit the filter even if the developer writing it doesn't know the rule exists.
This includes single-record lookups. Fetching a record by primary key and comparing its tenant field in application code after the fact still executes the query without a tenant predicate - it just catches the mismatch after the database has already confirmed the record's existence to an unauthorized caller.
A background worker starts up before the tenant context is populated, and a defensive fallback treats an empty scope as no restriction at all - the query runs and returns every tenant's rows. Separately, support tooling adds an ignoreTenantScope flag to a shared repository method, reachable eventually from a tenant-facing endpoint that passes it through by accident.
def scoped_query(query):
tenant_id = get_current_tenant()
if tenant_id is None:
raise TenantScopeMissingError("refusing to execute unscoped query")
return query.filter(tenant_id=tenant_id)An unset tenant identifier is never a wildcard or implicit permission to query across tenants - it's an error. The one legitimate cross-tenant query gets its own dedicated module sharing no code path with tenant-facing handlers, not a flag on a method everyone else also calls.
Diagnostic worth running: grep the codebase for any tenant-scoped query method that accepts a flag capable of disabling its own tenant filter. Every hit is a latent bypass waiting for a caller who didn't know it was there.
A job gets enqueued from inside a request handler with a fully populated tenant context - and the payload carries the record IDs it needs, but not the tenant ID, because the tenant was sitting in thread-local state when the code was written. Background handlers don't inherit that state; they run outside the request lifecycle entirely.
await queue.add('generate-report', { reportId });await queue.add('generate-report', {
reportId,
tenantId: getCurrentTenant()
});
async function handleGenerateReport({ reportId, tenantId }) {
await withTenantScope(tenantId, async () => {
// every call inside here is now correctly filtered
});
}This is where the “database query is the boundary” model breaks down most visibly: the database-layer filter is still correctly enforced inside the worker. It's just enforcing against whatever tenant scope the worker happens to be in - which, without this principle, is none at all.
A cache key gets built from the resource ID alone - cache:invoice:4471 - because the cache sits behind a layer that already filtered by tenant, so filtering again feels redundant. Then a second tenant requests invoice 4471 of their own, the cache key collides, and they get the first tenant's cached invoice back. The database was never touched or wrong.
Every cache entry, object storage path, and search index document needs the tenant identifier as part of its key, prefix, or filter — never inferred from the resource ID or search terms alone. Elastic's own writing on multi-tenant indexing is direct: a shared index with no tenant filter returns matches from every tenant that has ever indexed a document there.
The same principle extends to what you expose externally: a sequential record ID tells an attacker how many records exist, independent of whether your authorization check is correct. Tenant-owned records should get an opaque UUID reference, not the internal primary key — defense in depth, not a substitute for the authorization check itself.
A tenant reports slow performance, and the on-call engineer pulls logs to find out why - and every log line is missing a tenant dimension, because nobody's local reproduction of the bug ever needed one. The engineer can tell you the service is slow. They cannot tell you whether it's slow for one tenant or all of them.
Every log entry and metric from tenant-scoped code carries the tenant identifier as a structured field — the same discipline covered in Structured Logging Standards & Log Hygiene. Without it, a fault affecting one tenant is indistinguishable from a service-wide condition.
This is where the six principles above stop being a checklist one engineer can verify. A team that gets Principles 1 through 6 right in their own service has no way to confirm an async worker another team wrote re-establishes tenant scope, or that a search index a third team stood up applies a tenant filter - not from carelessness, but because isolation is a property of every service that exists, plus every one added next quarter.
Individually, each of these seven principles is something a competent engineer would agree with on sight. Collectively, across however many services and teams exist - and however many will exist after the next reorg - nobody has a standing view of whether all seven hold everywhere they need to. That’s what the three enforcement surfaces are for.
Every pull request is checked before merge. A tenant identifier read from a body, header, or query parameter; a new table missing its tenant column - these surface with the line and a fix.
An on-demand scan establishes the day-one baseline: tables missing a tenant column, cache key patterns copied without a tenant prefix, jobs that never carried a tenant field.
The same full scan, run on a cadence, catches drift introduced outside any PR - a new queue added without a tenant field, a log statement added mid-incident that skipped the tenant dimension.
Four of the checks that ship in this package - each runs at the PR level, on full or scheduled scans, or several at once.
Multi-tenant data isolation is the set of controls that guarantee one customer's data can never be read or written by another, in a system where all customers share the same application and often the same database. It spans the storage model, the query layer, caching, background jobs and exports. Isolation is a property of the whole path a request takes, not of the schema alone.
It is also the category with the least forgiving failure mode. Most bugs cost latency or correctness. This one costs a disclosure notice.
Row-level security (RLS) is a database feature that attaches a policy to a table so every query is automatically filtered by a session variable, typically the tenant ID. Postgres, SQL Server and Oracle all support it. Its strength is that it applies even to queries the application developer forgot to scope.
It does not replace application-level checks, it backstops them. RLS only works if the session variable is set correctly on every connection, which means a connection pool that hands out a session without resetting it reintroduces exactly the leak RLS was meant to prevent.
Silo gives each tenant its own database or cluster, bridge gives each tenant a separate schema inside a shared database, and pool puts all tenants in shared tables separated by a tenant ID column. Isolation strength decreases and operational efficiency increases as you move from silo toward pool.
Most SaaS platforms end up running more than one at once: pool for the long tail, silo for the enterprise contract that demanded it. That mix is the real design problem, because code written against the pooled assumption gets reused against the siloed one.
Yes, and most large SaaS platforms run exactly that. Pooled tenancy is safe when the tenant filter is structurally impossible to omit: enforced by RLS or a mandatory query layer rather than by developer memory, applied identically to background jobs, caches and exports, and covered by tests that attempt cross-tenant reads rather than only asserting the happy path.
What makes it unsafe is not the model, it is relying on every developer adding WHERE tenant_id = ? correctly, forever, including in the analytics query written under deadline pressure by someone who has never seen the isolation doc.
Audit the paths that bypass the normal request context, because that is where leaks live. Check every background job, scheduled task, admin endpoint, data export and cache key for whether the tenant scope is applied and whether the cache key includes the tenant ID. Then check whether any raw SQL exists outside the repository layer.
The direct test is better than the audit: write an integration test that authenticates as tenant A and requests a known resource ID belonging to tenant B, for each resource type. Anything that returns 200 instead of 404 is a live finding.
They catch the paths someone thought to test. A penetration test samples the surface at a point in time, and the leak that matters is usually introduced three sprints later in a new export endpoint or a cache key that omitted the tenant prefix. Neither approach sees the change at the moment it is written.
Continuous enforcement covers the gap: an isolation rule checked against every pull request that touches a query, a cache key or a job, plus scheduled scans catching the paths added since the last review.
You'll get this as a guideline MD file - hand it straight to your own coding agent or CI setup. Or skip the DIY step: give these same guidelines real enforcement superpowers, with PR checks and scans live on your repos, through Pandorian.
Every rule in this guide is easy to agree with in a design review and easy to violate six sprints later, one locally-correct decision at a time. Pandorian turns boundary discipline like this into CI guardrails - scoped to the repos, teams or services you choose.