AuthzGENERALDATABASE-SPECIFICFRAMEWORK-SPECIFIC

Tenant Isolation

The tenant comes from the authenticated principal. Any other source — header, subdomain, path, body — is an authorization bypass with extra steps.

What actually happensHow to build it

The requirement, the obvious build, and why it breaks

Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind has a problem.

The question

Where does the tenant identifier come from, and how do you make it impossible to forget?

The requirement

Nothing belonging to Acme may ever be visible to Globex, through any endpoint, cache, file, export, job or log — including endpoints written by an engineer who joined last week.

The obvious build

The client already knows its tenant, so let it tell us: read X-Tenant-Id from the header (or the subdomain, or a tenantId field in the body) and use it to scope queries. It keeps the API flexible and makes tenant switching easy for our admin tools.

Why it breaks

The header is client-controlled. Changing one value in a request reads another customer's data, and the request is otherwise completely legitimate: valid session, valid route, valid permission.

How it breaks in production
  • The header is client-controlled. Changing one value in a request reads another customer's data, and the request is otherwise completely legitimate: valid session, valid route, valid permission.
  • The subdomain is equally client-chosen. A session cookie scoped to the parent domain plus a visit to another tenant's subdomain is the entire attack.
  • Even if the header matches today, nothing enforces it. The check "does this header match the session" has to exist and be correct on every path, which is precisely the fragility you were trying to avoid.
  • Once one code path trusts a client-supplied tenant, everything downstream inherits it: the cache key, the file prefix, the job payload, the log label. The bad value propagates into stores that outlive the request.
  • Admin and support tooling then reuses the same mechanism, so the "flexibility" becomes the impersonation feature — unlogged, unbounded and reachable by anyone who can set a header.
RequirementAPI ContractApplication LogicData AccessExternal DepsConcurrencyFailureSecurityObservabilityDeploymentScale

What is actually happening

  • The tenant is an attribute of the principal, resolved at authentication time from the session record, the verified token claim or the API key's owner. It is derived once, from data the server wrote, and carried in request context from there (Request Context Propagation).
  • A client-supplied tenant is a claim in exactly the sense of The Trust Boundary: {"tenantId": "acme"} means "I assert I belong to Acme". It may be used as a *selector* among tenants the principal already belongs to, and never as the source of truth.
  • For principals that legitimately span tenants — a user who is a member of several organizations — the request may select which one, and the server must verify the selection against the principal's memberships before using it. Selection is validated; it is not trusted.
  • Making isolation structural rather than remembered is the whole game. The three mechanisms in practice: a data access layer that cannot be called without a tenant, a query layer that injects the predicate automatically, and row-level security in the database that enforces it even when the application forgets (Where the Check Belongs).
  • Row-level security with a pooled connection depends on session state: the application sets a session variable to the current tenant, the policy reads it, and the pool must reset it when the connection is returned. A connection handed to the next request with the previous tenant still set is a cross-tenant leak with a perfectly correct-looking policy (Connection Pools).
  • Everything that outlives the request needs the tenant baked into its identity: cache keys, object keys, index names, job payloads, file names of exports. These are the leaks that no WHERE clause covers (Multi-Tenancy).

One source of truth, resolved once

Everything in this lesson rests on a single rule: the tenant is derived from the authenticated principal, at the edge, and stored in request context. Any code that reads it from anywhere else is a bug regardless of whether it currently produces a wrong answer.

Requests from users who belong to several tenants are the case that tempts people back into trusting input. The resolution is not to trust the selection, but to validate it: the client may say which of *its* tenants to act as, and the server checks that claim against membership before it becomes context.

Resolving the tenant for a multi-org user
Trusting the selector
// tenant chosen by the client
const tenantId = req.header('X-Tenant-Id')
const ctx = { userId: session.userId, tenantId }
// every downstream query is now correctly scoped
// ...to a tenant the caller picked
Validating the selector
const requested = req.header('X-Tenant-Id')
const memberships = await memberships.forUser(session.userId) // server-side truth

const tenantId = requested
  ? memberships.find((m) => m.tenantId === requested)?.tenantId
  : session.defaultTenantId

if (!tenantId) return res.status(403).json({ error: 'not_a_member' })
const ctx = { userId: session.userId, tenantId } // validated, then trusted

Both versions end with a correctly-scoped query. Only the second guarantees the value being scoped to is one the caller is entitled to — the predicate is only as trustworthy as the value in it. Note also that the first version is safe from SQL injection and completely broken for authorization; those are independent properties (The Trust Boundary).

Making it structural

A rule that every query must be scoped is a rule that will be broken, because it depends on every engineer remembering it on every query forever. The durable versions make the unscoped path either impossible or conspicuous.

These layer. Application-level scoping is the primary control; the database policy is a backstop for the day the primary control is bypassed; the test is what tells you before a customer does.

Layers of tenant enforcement, outermost first
  1. 1
    Resolve at the edge

    Derive tenant from the principal; validate any selector against memberships

    fails by Reading a header or subdomain and using it directly

  2. 2
    Carry in request context

    Per-request storage that every layer reads from

    fails by A module-level variable shared by concurrent requests (Backend Races)

  3. 3
    Require it at the data boundary

    Repository methods take a tenant context; no table access outside them

    fails by A raw query written for a report, outside the repository

  4. 4
    Inject the predicate

    Query layer or ORM scope adds tenant_id = ? to every tenant-scoped table

    fails by An escape hatch — unscoped, native SQL, bulk update — that skips the scope

  5. 5
    Backstop in the database

    Row-level security evaluates the policy even if the app forgot

    fails by Connecting as an exempt role, or pooling that leaks the session variable

  6. 6
    Namespace derived stores

    Tenant prefix on cache keys, object keys, index names, export filenames

    fails by A key built from an object id alone

  7. 7
    Carry into async work

    Tenant in the job payload; workers build the same scoped context

    fails by A worker that looks the tenant up with an unscoped read

  8. 8
    Prove it in CI

    Two-tenant fixture asserting every endpoint returns nothing for the other tenant

    fails by Testing endpoint by endpoint, so the new endpoint is untested

The first two steps are the ones with no acceptable alternative. The rest are defence in depth, and each has a documented way of being bypassed — which is why none of them alone is the answer (Defence in Depth).

Row-level security, and the pooling trap

DATABASE-SPECIFICThis is Postgres syntax and Postgres semantics — FORCE ROW LEVEL SECURITY and current_setting have no MySQL equivalent, and SQL Server expresses the same idea as a security policy with an inline table-valued predicate function bound with SESSION_CONTEXT. The pooling hazard is common to all of them: any per-connection state must be scoped to the transaction or explicitly reset on return.

Row-level security is the strongest backstop available for a shared schema: the predicate is enforced by the database for every statement, including the raw query someone wrote at 2am. Its correctness depends entirely on the connection knowing who the current tenant is.

That is where pooling bites. A pool hands out connections that persist across requests, so a session variable set by one request is still set for the next one unless something resets it. The policy is correct, the query is correct, and the tenant is the previous caller's.

A policy, and the session state it depends on
1-- once, per tenant-scoped table
2ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
3ALTER TABLE invoices FORCE ROW LEVEL SECURITY; -- also applies to the table owner
4
5CREATE POLICY tenant_isolation ON invoices
6 USING (tenant_id = current_setting('app.tenant_id', true)::uuid);
7
8-- per request, on the connection you are about to use
9SET LOCAL app.tenant_id = '…'; -- LOCAL: reset at transaction end
10
11-- the trap, written out:
12-- SET app.tenant_id (no LOCAL) persists for the life of the
13-- connection, and the pool hands that connection to the next
14-- request. Correct policy, correct query, previous tenant.

Three details decide whether this works: FORCE (without it the table owner bypasses the policy entirely, which is often how the application connects), SET LOCAL inside a transaction so the value cannot outlive it, and the true second argument to current_setting so a missing setting returns NULL rather than raising — which makes the predicate false and denies, rather than erroring in a way something might catch and ignore.

How to build it

Most important first.

  • Resolve the tenant from the principal at the edge, put it in request context, and never read it from anywhere else in the codebase. One resolution point, auditable in one file.
  • If a request may select among tenants, validate the selection against the principal's memberships and then use the *validated* value, not the raw one.
  • Make the unscoped data path awkward or unavailable: a repository whose methods take a TenantContext, a base query that always includes the predicate, or a lint rule that rejects raw table access outside the repository (The Repository Layer).
  • Add row-level security as a backstop where the engine supports it, and test that it works — a policy that is not enforced because the app connects as a privileged role gives false confidence (Database Privileges and Blast Radius).
  • Prefix every cache key, object key and index with the tenant, mechanically, in a shared helper rather than at call sites.
  • Make background jobs carry the tenant in the payload and construct the same scoped context a request would, so worker code and request code use identical accessors (Job Queues).
  • Build impersonation as an explicit, logged, time-boxed capability with its own permission — not as "set the tenant header". Record both the real actor and the impersonated tenant on every action (Audit Logs for Privileged Actions).
  • Write a cross-tenant test as a fixture: two tenants, and an assertion that every resource endpoint returns nothing for the other one. Run it in CI for every new endpoint (Test Against the Real Database).

What can go wrong

Failure modes
  • A raw query written for a report that omits the predicate, because it was written outside the repository (Raw SQL in Application Code).
  • A pooled connection returned with the tenant session variable still set, so the next request evaluates policy as the previous tenant.
  • An ORM global scope that is bypassed by unscoped, withoutGlobalScope, a raw query, or an aggregate that goes around the model (What an ORM Actually Does).
  • A cache key built from an object id alone, so tenant A's cached response is served to tenant B (Cache-Aside).
  • An object storage key that a client can influence, letting one tenant write into another's prefix (Presigned URLs).
  • A job payload containing an id but no tenant, so the worker has to look the tenant up — and looks it up unscoped.
  • Log lines and error reports carrying tenant *content* rather than a tenant *label*, so an aggregated error tracker becomes a cross-tenant data store (Secrets in Logs).
  • A "select tenant" dropdown for staff that reuses the ordinary tenant-selection mechanism, so the bypass and the feature are the same code path.
What can race
  • A pooled connection's tenant session variable outliving the request that set it, so a concurrent request on the same connection evaluates policy under the wrong tenant. Set on checkout and reset on return, and test it under concurrency.
  • A user removed from a tenant while a request is in flight — the request completes with access that has just been revoked.
  • Request-context storage implemented with a mutable module-level variable rather than per-request storage, so concurrent requests in one process overwrite each other's tenant (Backend Races).
Security
  • If the tenant is read from a client-supplied header, path, subdomain or body field, an attacker with any valid account gets every tenant's data by changing one value. There is no exploit to write — it is a normal, authenticated request with a different string in it.
  • If a tenant selection is accepted without checking membership, an attacker gets exactly the tenants they name. Validating the selection against the principal's memberships is what turns a selector back into a safe input.
  • If row-level security relies on a session variable that pooling does not reset, an attacker gets nondeterministic cross-tenant reads — hard to reproduce, hard to detect, and dependent on which connection they happen to be handed.
  • If cache keys omit the tenant, an attacker gets whatever the previous requester cached, with no vulnerability in any handler. Timing the request is the only skill required.
  • If object storage keys are client-influenced, an attacker gets read or write access across tenant prefixes, including overwriting another tenant's files with content of their choosing (File Uploads Through the Backend).
  • If impersonation is implemented as tenant switching without a distinct permission and an audit record, an insider or anyone who compromises a staff account gets silent access to every customer.
  • If errors, logs or analytics carry tenant content, an attacker who reaches your observability stack gets a cross-tenant corpus that never passed through an authorization check at all.
Misreads
  • "The header matches the session, so it is safe." Then it is redundant, and the redundancy is a check someone will forget on one path. Derive it once and delete the header.
  • "The subdomain identifies the tenant." It identifies which page the browser asked for. Authorization comes from the session, not the hostname.
  • "The ORM adds the tenant scope automatically." Until someone uses a raw query, an aggregate, a bulk update, or an escape hatch — all of which exist and are used (What an ORM Buys and What It Costs).
  • "Row-level security means we cannot leak." It means queries cannot leak. Caches, files, indexes, jobs and logs are all outside its reach.
  • "Support needs to see customer data, so tenant switching must be easy." It must be *possible*, permissioned and logged. Easy and unlogged is the insider-threat path.

Operating it

How you see it in production
  • Assert in tests that no SQL leaves the application without a tenant predicate for tenant-scoped tables. Some teams enforce it at runtime in non-production via a query hook that raises on an unscoped table.
  • Log the tenant as a structured field on every request, job and error — as a label, never as content (Structured Logging).
  • Alert on any request where the resolved principal tenant and a request-supplied tenant hint disagree. In a correct system that count is zero; a nonzero value is either a client bug or probing.
  • Audit-log every impersonation with real actor, tenant, start, end and reason, and review it. This is the one cross-tenant path that is allowed to exist.
  • Periodically sample cache keys and object keys in non-production and assert they all carry a tenant prefix.
What changes at 10x and 100x
  • Nothing about the derivation changes with scale — one lookup per request, usually already loaded with the session.
  • The tenant predicate must be part of the index, not applied after it: index on (tenant_id, created_at) rather than (created_at), or a large tenant's query becomes a filter over everyone's rows (Composite Indexes and the Leftmost-Prefix Rule).
  • At many services, the tenant must propagate across service boundaries as part of the call context and be re-derived or re-validated at each boundary that owns data — a downstream service that trusts a caller-supplied tenant has the same bug one hop further away (Microservices).
What this costs
  • Structural enforcement — a repository that demands a tenant, a query layer that injects predicates — costs flexibility and adds friction to every legitimate query, including the genuinely cross-tenant ones your billing system needs.
  • Row-level security is a real backstop and moves part of your access control into the database, where application engineers do not test it, local development often skips it, and debugging is harder.
  • Tenant-prefixed cache keys reduce hit rates: two tenants requesting the same reference data cache it twice. That is the correct trade unless the data is provably tenant-independent.
  • A cross-tenant test fixture in CI costs setup time on every run and is the only mechanism here that catches the endpoint nobody thought about.

Where this applies

Backend advice is context-sensitive. These labels say what each claim is specific to, and where a different stack or scale would differ.

  • GENERALDeriving tenancy from the authenticated principal is universal. It has no legitimate exception in any stack.
  • DATABASE-SPECIFICRow-level security as a backstop exists in Postgres, SQL Server and Oracle; MySQL has no equivalent, so shared-schema isolation there rests entirely on the application. Postgres policies also do not apply to the table owner or a superuser unless FORCE ROW LEVEL SECURITY is set — a common reason a policy appears to do nothing.
  • FRAMEWORK-SPECIFICAutomatic tenant scoping exists as Rails default scopes, Django managers and Hibernate filters, and each has documented escape hatches (unscoped, raw querysets, native queries) that silently drop the predicate. Frameworks without an ORM have no automatic scoping at all, so the repository boundary has to carry it.

Where the depth lives

This domain teaches the application-side mechanism and hands the rest off.

Domains that do not exist yet
  • Testing & Reliability Engineering — a two-tenant fixture asserted across every endpoint is the highest-value integration test a B2B product can own.