Multi-Tenant Isolation
The highest-impact authorization bug in B2B software: one customer reading another customer's data.
In multi-tenant software, a cross-tenant read is not one user seeing another user's row. It is one company seeing another company's business — and it is the incident that ends contracts.
Why it is harder than per-user checks
Per-user ownership is usually a single column and a single relationship. Tenancy touches every table, every join, every background job, every export, every cache key, and every search index. One missed WHERE anywhere is a breach.
Where the tenant comes from
The verified credential. Never the request. A tenant id in a path, a header, or a body field is a claim, not a fact. If your API accepts /api/tenants/:tenantId/…, the path is a request to act in that tenant and must be checked against the caller's membership.
Three isolation models
| Model | Isolation | Cost |
|---|---|---|
Shared schema, tenant_id column | application-enforced | cheapest, most common, riskiest |
| Schema per tenant | database-enforced | migrations multiply |
| Database per tenant | strongest | expensive above a few hundred tenants |
Most products use the first and must therefore work hardest. Row-level security in the database is what turns "we remember to filter" into "the database refuses".
The places tenancy leaks
- Background jobs — a queued job has no request context, so the tenant must travel in the payload.
- Caches — a cache key without the tenant serves one company's data to another. This one is brutal and intermittent.
- Search indexes — a shared index needs a tenant filter on every query.
- File storage — object keys must be namespaced, and signed URLs scoped.
- Aggregates and analytics — a report that forgets the filter leaks totals.
- Connection pooling — a session variable set for one request must not survive into the next.
Example
// ❌ A cache key without the tenant. Intermittent cross-tenant leakage.
const key = `dashboard:${userId}`;
const key2 = `product:${productId}`; // product ids are per-tenant!
// ✅ Tenant in every key, always first
const key = `t:${tenantId}:dashboard:${userId}`;
const key2 = `t:${tenantId}:product:${productId}`;
// Better: a cache wrapper that cannot be called without one
class TenantCache {
constructor(tenantId) {
if (!tenantId) throw new Error('TenantCache requires a tenantId');
this.prefix = `t:${tenantId}:`;
}
get(k) { return redis.get(this.prefix + k); }
set(k, v, ttl){ return redis.set(this.prefix + k, v, 'EX', ttl); }
}When to use it
- A background job processes the wrong tenant's records because the tenant was read from a request-scoped global that was empty in the worker.
- A dashboard caches a summary under a key without the tenant, so one company briefly sees another's revenue figures.
- A shared search index returns another organisation's documents because the filter was applied in the UI rather than the query.
More examples
Tenant context that cannot be lost
Making currentTenant() throw rather than return null is the decision that matters: a null tenant silently becomes 'no filter', which is every tenant.
import { AsyncLocalStorage } from 'node:async_hooks';
// One context per request, propagated automatically through async calls.
const tenantContext = new AsyncLocalStorage();
export function withTenant(tenantId, fn) {
if (!tenantId) throw new Error('withTenant requires a tenantId');
return tenantContext.run({ tenantId }, fn);
}
export function currentTenant() {
const ctx = tenantContext.getStore();
// Throwing — not returning null — is the whole design. A query that runs
// without a tenant must fail loudly, never fall back to "all tenants".
if (!ctx?.tenantId) throw new Error('No tenant in context');
return ctx.tenantId;
}
// Entering the context, once, at the edge
app.use(auth, (req, res, next) => {
withTenant(req.user.tenant, () => next()); // from the CREDENTIAL
});
// Every query helper reads it — developers cannot forget
export function scoped(table) {
return db(table).where({ tenant_id: currentTenant() });
}
app.get('/api/invoices', async (req, res) => {
res.json(await scoped('invoices').orderBy('created_at', 'desc').limit(50));
});
// ── The part everyone gets wrong: background jobs ─────────────────────
// A worker has NO request, so the context is empty and currentTenant() throws.
// That is correct behaviour — carry the tenant in the payload.
await queue.add('generate-report', {
tenantId: currentTenant(), // captured at enqueue time
reportId,
requestedBy: req.user.id,
});
worker.process('generate-report', async (job) => {
const { tenantId, reportId } = job.data;
if (!tenantId) throw new Error('job missing tenantId'); // fail the job
await withTenant(tenantId, async () => {
const rows = await scoped('invoices').where({ report_id: reportId });
await buildReport(rows);
});
});Every place the tenant has to appear
Seeding two tenants in every test fixture is the cheapest structural change here — isolation bugs are undetectable in a single-tenant test database.
// A checklist you can walk per feature. Each line is a real incident somewhere.
// 1. QUERIES — every read, write, count and aggregate
await scoped('invoices').where({ status: 'open' });
// 2. CACHE KEYS — tenant first, always
const cache = new TenantCache(currentTenant());
// 3. SEARCH — a filter in the QUERY, not in the UI
await elastic.search({
index: 'documents',
query: { bool: {
must: [{ match: { body: q } }],
filter: [{ term: { tenant_id: currentTenant() } }], // ← not optional
} },
});
// 4. FILE STORAGE — namespaced keys, scoped signed URLs
const key = `tenants/${currentTenant()}/invoices/${id}.pdf`;
const url = await s3.getSignedUrl('getObject', { Key: key, Expires: 300 });
// ...and the bucket policy denies cross-prefix reads as a second layer.
// 5. RATE LIMITS AND QUOTAS — per tenant, so one cannot starve another
await rateLimiter.consume(`t:${currentTenant()}:api`, 1);
// 6. WEBHOOKS — deliver to THIS tenant's endpoint with THIS tenant's secret
const config = await scoped('webhook_configs').where({ event }).first();
// 7. METRICS AND LOGS — tagged, so an investigation can scope itself
logger.info({ tenantId: currentTenant(), event: 'invoice.created' });
// 8. EXPORTS AND REPORTS — the second path to the same rows
// 9. ADMIN AND SUPPORT TOOLS — explicit cross-tenant access, audited
// 10. TEST FIXTURES — seed TWO tenants, so isolation is testable at all
// The test that matters, and it belongs in CI:
it('no endpoint returns another tenant\'s data', async () => {
for (const route of readRoutes) {
const res = await request(app).get(route.path)
.set('Authorization', `Bearer ${acmeToken}`);
if (res.status !== 200) continue;
const ids = collectIds(res.body);
const foreign = await db('audit_ownership')
.whereIn('id', ids).whereNot({ tenant_id: acme.id });
expect(foreign).toHaveLength(0);
}
});Cross-tenant access, when it is legitimate
Notifying the customer is the control that changes behaviour: engineers browse far less casually when the account owner receives an email about it.
// Support and internal tooling genuinely need to cross tenants. Make that one
// narrow, loud, audited path rather than a general-purpose escape hatch.
export async function withCrossTenantAccess(actor, tenantId, reason, fn) {
// 1. A distinct permission — not 'admin', which too many people have.
if (!actor.permissions.includes('support:cross_tenant')) {
throw new ForbiddenError('cross_tenant_access_denied');
}
// 2. A reason is mandatory, and a ticket reference is better.
if (!reason || reason.length < 10) {
throw new BadRequestError('a reason of at least 10 characters is required');
}
// 3. Time-boxed: elevation lasts for the operation, not the session.
const grant = await db.crossTenantGrants.create({
actorId: actor.id, tenantId, reason,
expiresAt: new Date(Date.now() + 15 * 60_000),
});
// 4. Audited before the access, so a crash mid-operation still leaves a trace.
await audit.record({
event: 'cross_tenant.access', actor: actor.id,
tenant: tenantId, reason, grantId: grant.id,
});
await alertSecurityChannel(
`${actor.email} accessed tenant ${tenantId}: ${reason}`);
// 5. And the customer is told, because they are entitled to know.
await notifyTenantAdmins(tenantId, {
type: 'support_access',
message: `A support engineer accessed your data. Reason: ${reason}`,
});
try {
return await withTenant(tenantId, fn);
} finally {
await db.crossTenantGrants.close(grant.id);
}
}
// Usage — visible in review, greppable in the codebase
router.get('/support/tenants/:id/invoices', requireRole('support'), async (req, res) => {
const invoices = await withCrossTenantAccess(
req.user, req.params.id, req.query.reason,
() => scoped('invoices').limit(100),
);
res.json(invoices.map(InvoiceSerializer.support));
});
// Because it is one function, you can answer "who looked at customer data last
// month, and why?" with a single query — which is what an auditor will ask.
Discussion