Built — Describes systems I have built and run. Specifics are generalised where client work is involved.
Multi-tenancy is one schema decision and a hundred small opportunities to leak data across it. The schema decision gets all the attention. The hundred opportunities cause all the incidents.
#Three isolation models
| Model | Isolation | Cost per tenant | Onboarding | Cross-tenant reporting |
|---|---|---|---|---|
Shared schema, tenant_id column | Logical | Lowest | Instant | Trivial |
| Schema per tenant | Moderate | Medium | Seconds | Awkward |
| Database per tenant | Physical | Highest | Minutes | Hard |
Most products should start with the shared schema and a tenant_id column. It is the cheapest to operate, the easiest to migrate, and the only one where a thousand small tenants is not a thousand small operational problems.
Move up the table when something forces you: a contractual data-residency requirement, a compliance regime that demands physical separation, or one enterprise tenant whose data volume distorts everything around it.
A useful pattern once you have both: shared schema by default, dedicated database as a paid tier. The application code stays identical; only the connection routing differs.
#The dangerous bug
Cross-tenant leaks almost never come from an exotic attack. They come from a query someone forgot to scope:
// One missing clause. Every tenant's invoices.
const invoices = await db.invoice.findMany({ where: { status: 'unpaid' } });Code review catches this most of the time. Most of the time is not good enough when the failure mode is showing one customer another customer's data.
The fix is structural: make the unscoped query impossible to express. Do not expose a repository method that can be called without a tenant.
// The raw client is module-private. Nothing outside this file can reach it.
const client = new PrismaClient();
export function forTenant(tenantId: string) {
if (!tenantId) throw new Error('tenant context required');
return {
invoices: {
findMany: (where: InvoiceWhere = {}) =>
client.invoice.findMany({ where: { ...where, tenantId } }),
// Update and delete take the same treatment. A scoped read with an
// unscoped write is the version of this bug that destroys data
// instead of merely exposing it.
update: (id: string, data: InvoiceUpdate) =>
client.invoice.updateMany({ where: { id, tenantId }, data }),
},
};
}Isolation enforced by types beats isolation enforced by discipline. There is no code path that constructs an unscoped query, because the function that would do it does not exist outside the module.
#Row-level security as the backstop
Application-level scoping is your first line. Postgres row-level security is the one that holds when the ORM does something surprising, or when someone opens a psql session against production.
ALTER TABLE invoice ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON invoice
USING (tenant_id = current_setting('app.tenant_id', true)::uuid);Set app.tenant_id on the connection at the start of each request. The cost is one SET LOCAL per transaction. The benefit is that a forgotten WHERE clause returns zero rows instead of everyone's.
#Resolving the tenant once, at the edge
Tenant identity should be established exactly once and carried through everything downstream. Resolving it independently in each service is how two services end up disagreeing about who the caller is.
- CLIENT
Product Appsubdomain or token Admin Consolecross-tenant, audited - EDGE
API Gatewayauthn · tenant · RBAC · quota - SERVICES
Core APIscoped repositories Quota Serviceatomic counters Audit Logappend-only - STATE
PostgreSQLRLS enabled Rediscounters · sessions
Three ways to identify a tenant, in rough order of preference:
- 01A claim in the access token. Signed, unforgeable, no lookup. The right default.
- 02A subdomain (
acme.product.com). Good for branding, but must still be cross-checked against the token — otherwise changing a hostname is a privilege escalation. - 03A path segment or header. Convenient, and the easiest to get wrong. Never trust it without verification.
#Roles as data, not as conditionals
Permission logic scattered across services diverges. Adding a role becomes a change in eleven files, and nobody can answer "what can an analyst actually do?" without reading all of them.
Declare each role's reachable routes as data, and make the gateway a lookup:
export const ROLE_ROUTES = {
viewer: ['GET /reports', 'GET /reports/:id'],
analyst: ['GET /reports', 'GET /reports/:id', 'POST /reports', 'POST /exports'],
admin: ['GET /*', 'POST /*', 'PATCH /*', 'DELETE /users/:id'],
} as const;
// Deny by default: a route absent from the manifest is refused. New endpoints
// are invisible until explicitly granted, which makes the dangerous failure
// mode — accidental exposure — impossible by construction.A permissions review becomes reading one file per role. That is a difference you feel the first time a customer's security team sends a questionnaire.
#Noisy neighbours are a quota problem
Shared infrastructure means one tenant's bulk import can degrade everyone else. The answer is quotas enforced at the edge, on the same hop as authorisation.
Counters must be atomic, or concurrent requests will overspend a limit:
// INCR is atomic. A read-then-write here is a race that lets a burst of
// parallel requests each see "under limit" and collectively blow past it.
const key = `quota:${tenantId}:${period}`;
const used = await redis.incr(key);
if (used === 1) await redis.expire(key, PERIOD_SECONDS);
if (used > limit) {
throw new QuotaExceeded({ limit, resetsAt: periodEnd });
}Two refinements that matter in practice:
- Idempotency keys, so a client retry after a timeout does not burn quota twice.
- Periodic reconciliation to the relational store, because Redis is the hot path and not the system of record for billing.
#Migrations across many tenants
With a shared schema this is ordinary — one migration, one table. It is also where the shared model earns its keep.
With schema-per-tenant, a migration is a loop that will partially fail at tenant 340 of 800. That needs to be a resumable job with per-tenant status, not a deploy step. Either way, use expand–migrate–contract: add the new column, write to both shapes, backfill in batches, switch reads, drop the old column in a later release. A migration that requires the application to stop is a design decision, not a technical necessity.
#What I would not skip
- Audit log from day one. Actor, tenant, before, after. Written asynchronously so it cannot add latency to — or fail — the operation it describes. Retrofitting this after a customer asks "who changed this?" is painful.
- A tenant context in every log line. Debugging a single customer's problem in a shared system is impossible without it.
- An impersonation path for support, fully audited. Support will need to see what a customer sees. Building it deliberately is far better than the alternative, which is a shared admin password.
#Related
The Multi-Tenant Control Plane case file covers the concrete implementation, and the Systems canvas shows how the gateway, quota service and audit log fit into a full architecture.
Key takeaways
Pick the isolation model your compliance requirements demand, not the one that is easiest to demo. Make the unscoped query impossible to write rather than trusting reviewers to catch it. Row-level security is a backstop for the mistake your ORM will eventually make. Noisy neighbours are a quota problem, and quotas belong at the edge.