Multi-Tenant Isolation
A tenant_id column is not isolation. Isolation is that column enforced consistently across queries, caches, queues, storage, search, logs and AI context — every place a copy of the data exists.
Frame the problem
Security starts with a concrete asset, attacker capability and trust crossing.
Every copy is a boundary
The database is where teams enforce tenancy and the other stores are where it leaks. A cache keyed by invoice:101 serves tenant B's invoice to tenant A. A search index queried without a tenant filter returns everyone's documents. A queue consumer resolves an id without the tenant. A log line includes another tenant's payload in an error. An agent's retrieval pulls the most similar chunk from the whole corpus.
The rule: wherever data is keyed, the tenant is part of the key; wherever data is queried, the tenant is part of the predicate; wherever data is rendered, the tenant is checked against the principal.
| Store | Leak | Enforcement |
|---|---|---|
| Database | Query without tenant predicate | Row-level security policy or principal-scoped repository |
| Cache | Key without tenant | Key prefix t:{tenant}:… |
| Queue | Message resolves id without tenant | Tenant in message; consumer scopes by it |
| Object storage | Shared bucket, guessable keys | Per-tenant prefix + signed URLs scoped to the prefix |
| Search / vector index | Query without tenant filter | Mandatory metadata filter; per-tenant index for sensitive data |
| Logs | Payload of another tenant in an error | Redact bodies; log ids not content |
| AI context | Retrieval across tenants | Filter before retrieval, not after |
Row-level security as depth
Databases that support row-level security let you set the tenant once per connection and have every query filtered by policy. This is defence in depth: an application bug that forgets the predicate still cannot cross tenants. It costs setting a session variable per request and forbidding the application role from bypassing the policy. For high-value multi-tenant data it is the single most effective control available.
1ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;2CREATE POLICY tenant_isolation ON invoices3 USING (tenant_id = current_setting('app.tenant_id')::uuid);4-- app role cannot BYPASSRLS; per request: SET LOCAL app.tenant_id = '<from session>';5-- A query that forgets WHERE tenant_id = ... now returns only this tenant's rows anyway.Key points
- A tenant column is not isolation; consistent enforcement in every store is.
- Tenant in every cache key, queue message, storage prefix and index filter.
- Row-level security is depth against forgotten predicates.
- Filter AI retrieval before the query, not after.
Boundary control exercise
This lesson uses the shared boundary-control exercise.
Follow the attack
Safe conceptual simulation: capability → missing control → crossed boundary → asset impact.
- 1Tenant A → guess or observe an id, hit a cache/search/export path lacking the tenant filter.
- 2Receive tenant B's data.
- Cross-customer disclosure: contractual, regulatory and reputational, often product-ending.
Defend, detect, recover
One prevention is a single point of security failure. Layer it and make failure observable.
- • RLS or scoped repositories.
- • Tenant-prefixed keys everywhere.
- • Cross-tenant tests per store.
- • Per-tenant encryption keys for highest-value data.
- • Requester tenant ≠ record tenant in access logs.
- • Cache hit for a key lacking a tenant prefix.
- • Identify affected tenants from logs; notify; add the missing enforcement in every store, not one.
- • Shared infrastructure remains shared; noisy-neighbour and side channels persist.
- • Support tooling crosses tenants by design.