Security

Multi-Tenant Data Isolation: Best Practices for Shared Infrastructure

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.

8 min read · 8 Principles · Compliance & Regulation
A 3D lattice of transparent cube frames with two solid cubes—white at the front and an orange cube resting on the grid.

TL;DR

  • The tenant identifier comes from the authenticated principal, never from anything the client sends in a body, header, or path.
  • Every tenant-owned table - including child and join tables - carries its own tenant column; a join to a parent table is not a tenant boundary.
  • The tenant filter lives in exactly one shared mechanism that every query passes through, not in the judgment of whoever wrote that query.
  • A missing tenant scope is a hard failure, not a wildcard, and the one legitimate reason to query across tenants gets its own isolated code path.
  • Background jobs and queues don't inherit request context automatically - the tenant identifier has to ride in the payload and get re-established at the top of the handler.
  • Caches, object storage, search indexes, and external identifiers are each their own tenant boundary, and each has already been bypassed by a team that isolated the database correctly.
  • You cannot verify a boundary you haven’t instrumented, and by the time enough services exist to make that true, no single team is positioned to check it.

What Multi-Tenant Data Isolation Actually Protects

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.

1. The Tenant ID Never Comes From the Caller

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.

ANTI-PATTERN — tenant taken from client input
const tenantId = req.body.tenantId ?? req.headers['x-tenant-id'];
CORRECTED — tenant taken from the verified principal
const { tenantId } = req.auth.claims; // set by JWT verification middleware, not by the client

This 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.

2. Every Tenant Table Carries Its Own Tenant Column

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.

ANTI-PATTERN — tenant reachable only through a join to the parent
CREATE TABLE line_items (
  id BIGINT PRIMARY KEY,
  order_id BIGINT REFERENCES orders(id)
);
CORRECTED — every tenant-owned table carries its own tenant column
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.

3. One Filter Enforces Every Tenant-Scoped Query

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.

CORRECTED — the filter lives in the database, not in application judgment
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.

4. A Missing Scope Fails the Request, Not a Bypass

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.

CORRECTED — an unset scope fails before the query runs
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.

5. Async Work Forgets the Tenant by Default

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.

ANTI-PATTERN — tenant context assumed to survive into the worker
await queue.add('generate-report', { reportId });
CORRECTED — tenant travels with the job and scope is re-established explicitly
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.

6. Every Secondary Store Needs Its Own Tenant Boundary

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.

7. You Can't Enforce a Boundary You Can't See

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.

How Pandorian Enforces This Guide

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.

Caught at PR level

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.

Full scans

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.

Scheduled scans

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.

A Taste of the Guideline Package

Four of the checks that ship in this package - each runs at the PR level, on full or scheduled scans, or several at once.

RULE WHAT IT FLAGS
SEC_41093
Tenant identifier read from a request body field, query parameter, or client-supplied header instead of the authenticated principal.
SEC_74619
Single-record lookup by primary key or external ID with no tenant identifier in the query predicate.
SEC_82613
Tenant filter disabled via a flag on a method shared with tenant-facing code, instead of isolated to a dedicated admin path.
SEC_67052
Enqueued job or published message payload missing a tenant identifier field.

FAQ

What is multi-tenant data isolation?

+

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.

What is row-level security, and does it replace application-level checks?

+

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.

What is the difference between silo, bridge and pool tenancy models?

+

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.

Is a shared database with a tenant_id column ever safe enough?

+

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.

How do I tell if a cross-tenant leak is possible in our codebase?

+

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.

Can't tests or a penetration test catch cross-tenant leaks?

+

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.

Get the Guideline Package

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.

From Principles to Enforcement

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.