The whole design, in writing
Every step of the build above, written out: the problem each piece solves, the option that was taken and the ones that were not, the numbers, and how it fails in production.
The big idea
What is multi-tenant isolation?
You serve many customers (tenants) from one system to keep it efficient and cheap to operate. But each tenant’s data, performance, and configuration must be strictly separated — tenant A must never see tenant B’s data, and one customer’s bad day must not become everyone’s outage. One missing filter is a cross-tenant data leak, the worst kind of bug.
Inject a tenant context on every request, scope all data access to it, choose an isolation model matched to the customer’s trust needs, cap noisy neighbors with per-tenant quotas, encrypt with per-tenant keys, and prove it with a tenant-scoped audit. Many customers, one system, strictly separated.
Step 1 · The skeleton
Many customers, one system
The naive version: all tenants hit the same Gateway and read from the same shared store, with nothing distinguishing them. It’s efficient — and it’s a data breach waiting to happen, because any query could return any tenant’s rows.
Stand up a shared Gateway and store serving all tenants. This is the efficient core of multi-tenancy; every step from here adds the separation that makes sharing safe.
Why this piece earns its place
The thing this skeleton quietly decides is what a tenant actually is, and at this stage it looks like a label hanging off a user — one person, one customer, look it up at login and be done. That shortcut is right for almost every account you will ever have, which is exactly why it survives long enough to hurt. Real customers are not flat: an enterprise arrives with subsidiaries, a sandbox beside production, and people who legitimately sit in more than one of them — a consultant on two accounts, a support engineer who has to look at yours. So I would write the id down as a property of the request from the first day, even while the lookup behind it is still one-to-one. The request names the tenant it is acting in, and resolving it is then a membership check — is this user allowed here — rather than a lookup of the one tenant a person owns. Nothing downstream has to change shape the day somebody has two, because every key, index, audit row and cache entry was already keyed on the request’s tenant and not on the person. The second decision is the id itself: make it opaque and non-sequential, because a tenant id that looks guessable turns every endpoint into an enumeration test.
What the new pieces do
- Tenant Aclient
- Users belonging to one customer. They must only ever see customer A’s data — and never even be able to tell that customer B exists on the same system.
- Tenant Bclient
- A second customer sharing the same deployment. Isolation from tenant A must hold for data, performance, and configuration — one system, strictly separated worlds.
- API Gatewaybackend
- The single front door. Authenticates the user, resolves which tenant they belong to, and stamps that tenant id onto the request so nothing downstream has to guess.
Step 2 · Who is this?
Inject tenant context
If each piece of code has to remember to filter by the right customer, someone eventually forgets — and a single unscoped query leaks one tenant’s data to another. You can’t rely on discipline for something this catastrophic.
A single query that forgets to filter by tenant leaks data across customers. How do you prevent that reliably?
Review helps but humans miss things, and one missed filter is a breach. Isolation this critical can’t depend on everyone remembering on every query forever — it needs to be enforced automatically.
Forking the app per tenant is a maintenance nightmare and still doesn’t stop a shared query from crossing tenants. The fix is a single system that carries and enforces tenant context automatically.
Authenticate, resolve the tenant, and stamp the tenant id into a request context that the data layer applies to every query by default. Isolation is enforced centrally, so no individual query can forget it.
Add a Tenant Resolver: authenticate the user, map them to a tenant, and inject the tenant id into the request context. The data layer applies it to every query automatically, so isolation is enforced by the framework, not by each developer remembering.
Why this piece earns its place
Central enforcement fixes the request path and creates a new blind spot: everything that runs without a request. Background jobs, queue consumers, scheduled reports, data migrations, the analytics export, the internal admin tool — none of them arrive through the gateway, so none of them carry a tenant. That is the dangerous shape, because those are exactly the code paths that read and write in bulk. The defense is to make an absent tenant context a hard failure rather than an empty filter: if the data layer cannot find a tenant, it refuses the query instead of quietly scoping to nothing — or, far worse, to everything. Work that legitimately spans customers then has to ask for it, one tenant at a time where it can, through a named elevation recorded with whoever invoked it. The rule I want is that crossing a tenant boundary is always a deliberate, visible act, never the default a missing value falls back to.
- 1 placeto enforce
- autoscoped queries
- 0forgotten filters
What the new pieces do
- Tenant Resolverservice
- Maps the authenticated user to a tenant and injects the tenant id into the request context — the thing every query and check downstream keys off of.
Back of the envelope
- resolve tenant at the gateway
- from the authenticated identity
- inject tenant id into context
- threaded through every call
- data layer scopes by default
- no query can forget the filter
Step 3 · Where does data live?
The isolation spectrum
A shared table filtered by tenant id is cheap and easy to operate, but one bug crosses tenants and a regulated customer may reject "your data sits in the same table as everyone else’s." Stronger isolation costs more to run. There’s no single right answer.
How strongly should you isolate tenant data — shared table, schema-per-tenant, or database-per-tenant?
DB-per-tenant is the strongest but the most expensive and operationally heavy — thousands of databases to migrate, back up, and monitor. It’s right for high-trust/regulated tenants, but overkill and unaffordable as a blanket default.
It’s a spectrum. A shared table filtered by tenant id is cheapest and scales to many small tenants; a schema or database per tenant gives stronger separation for regulated or high-value customers. Match the model to each tenant’s trust and compliance needs.
A shared table is efficient but the weakest isolation — one bug crosses tenants, and some regulated customers won’t accept co-mingled storage. It’s a fine default for many tenants, but not a universal answer.
Pick the Isolation Model per customer along a spectrum: a shared table filtered by tenant id (cheapest, most tenants), a schema per tenant (stronger, still shared DB), or a database per tenant (strongest, most operational cost). Match strength to the customer’s trust and compliance needs.
Why this piece earns its place
Choosing per customer means you do not run one system, you run three at once, and that cost lands on the team rather than on the diagram. Every schema change now has to apply to the shared table, to each tenant schema, and to each dedicated database — so a migration stops being one statement and becomes a fleet rollout that can half-succeed, leaving customers on different versions of the same product. What keeps that affordable is making the isolation model a routing decision rather than a branch in business logic: the app resolves a tenant to a connection and a scope from tenant metadata, and feature code is written once against that. The other thing to commit to early is the move between models. The first large deal will ask to be lifted out of the shared store into its own, and a path that has never been run is a rewrite under a deadline. Treat promotion as a supported, tested migration from the day the spectrum exists.
- row · schema · dbspectrum
- cost ↕ separationtradeoff
- per-tenantchoice
What the new pieces do
- App Tierservice
- Shared application servers that serve all tenants — but every read and write is automatically scoped to the request’s tenant id, so logic can never cross tenants.
- Tenant Datadata
- The isolation spectrum: a shared table filtered by tenant id (cheapest), a schema per tenant, or a database per tenant (strongest). Chosen per the customer’s trust and compliance needs.
- Isolation Modelservice
- The chosen isolation strategy per customer — from a fully shared, tenant-filtered store to a dedicated silo. A dial between cost/efficiency and strength of separation.
Back of the envelope
- shared table + tenant id
- cheapest, scales to many tenants
- schema per tenant
- stronger, one DB to run
- database per tenant
- strongest, highest ops cost
Step 4 · The noisy neighbor
Per-tenant quotas
Data isolation isn’t enough. One tenant sends a flood of traffic or runs a monster query, consuming the shared app and database capacity — and every other tenant slows down or errors. Isolation has to cover performance, not just data.
One tenant’s traffic spike degrades every other tenant on the shared infrastructure. How do you contain it?
Cap each tenant’s request rate and resource use (connections, query cost) so one customer’s spike is throttled to its own budget instead of consuming the shared pool. The noisy neighbor is contained to its own lane.
Auto-scaling helps globally but doesn’t stop one tenant from monopolizing capacity faster than you can add it, and it makes one customer’s abuse everyone’s cost. You need per-tenant limits, not just more capacity.
You can’t rely on a customer to self-limit, and by the time you notice, others are already degraded. Fair sharing has to be enforced by the system with per-tenant quotas.
Add Per-Tenant Quotas: rate limits and resource caps applied at the gateway per tenant, so one customer’s spike is throttled to its own budget instead of draining the shared pool. Performance isolation, alongside data isolation.
Why this piece earns its place
The number itself is the hard part, and both directions hurt. Set it low and you throttle a paying customer during their own peak — a monthly import, a seasonal rush — and a rejected request is an outage they will blame on you, not on their spike. Set it high enough that nobody ever reaches it and you have built a limit that never binds. Customer sizes in a SaaS are wildly skewed, so one global number is either useless for the largest tenant or lethal for it; I would derive each cap from that tenant’s own observed traffic plus headroom, and tie it to what they bought. I would also meter the expensive thing rather than the easy thing — one request can be a full-table report, so a request-rate limit measures the wrong resource. And a ceiling alone is not fairness: if the sum of everyone’s ceilings exceeds capacity, a small tenant can still be starved. Reserve a floor per tenant too, so being small is not the same as being last.
- per-tenantrate limits
- resourcecaps
- nonoisy neighbor
What the new pieces do
- Per-Tenant Quotacache
- Enforces per-tenant rate limits and resource caps so one customer’s traffic spike can’t starve the shared app and database — the fix for noisy neighbors.
Back of the envelope
- rate-limit per tenant
- a spike hits only its own budget
- cap resource use
- connections, query cost, compute
- fairness enforced
- not left to good behavior
Step 5 · Separate secrets
Per-tenant keys + config
If every tenant’s data is encrypted under one shared key, a single key compromise exposes everyone, and you can’t cleanly delete or export one tenant. And tenants often need their own configuration — features, limits, branding.
Give each tenant its own encryption key and its own configuration. Encrypting a tenant’s data under its own key means a key leak exposes only that tenant and enables true per-tenant deletion (drop the key). Per-tenant config lets one system behave differently for each customer.
Why this piece earns its place
Crypto-shredding rests on one assumption — that the tenant’s key is the only path to its plaintext — and that assumption breaks the day someone builds something useful out of the data. A search index, a cache, an exported report, a warehouse table behind a dashboard, an error log that captured a payload: each of those holds the customer’s content in the clear, and destroying the key does nothing to any of them. Deletion then reads as done, the certificate goes out, and copies remain. What makes the property real is an inventory — every derived store either holds no tenant content, or is encrypted under that same tenant key, or has its own deletion hook exercised by the same test that destroys the key. There is also a cost to notice now: the read path has acquired a dependency on a key service. Wrap a per-tenant data key rather than calling out on every read, or a slow key manager becomes a single customer’s outage that no aggregate error rate will show you.
- 1 keyper tenant
- boundedblast radius
- key delete= data delete
What the new pieces do
- Per-Tenant Keysdata
- Each tenant’s own encryption key and configuration. Encrypting a tenant’s data under its own key means a leak of one key exposes only that tenant, and enables per-tenant deletion.
Back of the envelope
- encrypt under a per-tenant key
- a leak exposes one tenant, not all
- crypto-shredding
- destroy the key = delete the data
- per-tenant config
- features, limits, branding
Step 6 · Prove it holds
Audit + isolation testing
Isolation you can’t verify is isolation you can’t trust — and a regulated customer will demand evidence. Worse, a subtle bug could be leaking across tenants right now with no visible symptom.
Add a tenant-scoped, immutable Audit trail of who accessed and changed what, per tenant — the evidence compliance requires. And test isolation continuously: automated checks that a tenant A token can never read tenant B data, run in CI. Isolation becomes provable, not assumed.
Why this piece earns its place
A cross-tenant test proves exactly what it enumerates, and leaks happen where nobody thought to write one — the endpoint added last week, the export job, a response cached under a key that forgot the tenant, an error message echoing another customer’s record id. A hand-maintained list drifts the moment the product grows. So I would generate the list instead of writing it: walk the route table at test time and fail the build on any route with no cross-tenant case attached, so a new endpoint is a failing test until someone shows that tenant A’s token cannot use it against tenant B. The audit trail deserves the same scepticism. A leak is a read, so a trail that records only writes cannot see the failure this whole page is about. And a log the app tier can rewrite is not evidence — it has to be append-only and outside the reach of the code it is watching, or all it proves is that nothing went wrong in a way that also broke the logging.
- per-tenantaudit
- immutabletrail
- CIisolation tests
What the new pieces do
- Per-Tenant Auditdata
- An immutable, tenant-scoped audit trail of access and changes — required for compliance and the evidence that isolation is actually holding.
Back of the envelope
- tenant-scoped audit log
- who did what, per customer
- immutable + exportable
- compliance evidence
- cross-tenant tests in CI
- A’s token must never read B
You did it
You just designed multi-tenant isolation.
Everything you assembled, in order
- A shared gateway + store is the efficient core; the discipline is making sharing safe.
- Resolve the tenant at the edge and inject a tenant id that scopes every query automatically.
- Choose the isolation model per customer: shared+tenant-id, schema-per-tenant, or DB-per-tenant.
- Per-tenant quotas isolate performance so a noisy neighbor can’t degrade everyone.
- Per-tenant encryption keys bound a key compromise to one tenant and enable clean deletion.
- A per-tenant audit trail plus automated cross-tenant tests make isolation provable.