Lindsay Edwards

Multi-tenant isolation is a series of footguns

On this page

If you build multi-tenant software, at some point you draw a confident little diagram: one central filter, every query scoped to the current tenant, isolation solved. Then you go looking, and you find that “solved” had five holes in it. I have now seen the same set of holes in enough different codebases that I have stopped treating them as bugs and started treating them as a checklist.

The pattern is always the same. Isolation looks centralised. The leaks are in the places that quietly do not go through the centre. Stated plainly, every hole below is the same failure: one customer reading or overwriting another customer’s data, which for a business is close to the worst bug there is.

The fail-open default#

A common design gives unauthenticated or system requests a sentinel tenant, often an empty id, and lets the global query filter treat that sentinel as “can see everything” so background jobs and lookups work.

That default is load-bearing and it is a landmine. Now every endpoint that forgets an authorisation check does not just leak its own data. It resolves to see-all scope, so a missing check becomes a full cross-tenant read. Defaults for a security scope should fail closed: deny, or see nothing. A privileged all-tenants scope should be explicit and impossible to reach from anonymously-reachable code.

Binding the database entity straight from the request#

Here is a beautiful one. Isolation is enforced by a save interceptor that stamps the tenant id on new rows, but only when the id is blank (“fill if empty”). Several endpoints bind the ORM entity directly from the JSON body. So a client sends {"tenantId": "<someone-else>"}, the interceptor sees a non-empty value and politely leaves it alone, and the row is written into another tenant. The read filter then hides it from the attacker, so it shows up as silent pollution rather than an obvious error.

Two lessons in one. Never model-bind your database entities from request payloads; put a DTO in between so fields like tenantId cannot be mass-assigned. And for a security invariant, “fill if blank” is not enforcement. Overwrite the value authoritatively.

The query-filter escape hatch#

You add a global query filter to every entity and feel safe. Then a by-id lookup still leaks across tenants, because the ORM’s load-by-primary-key method (in one popular framework, FindAsync) bypasses global query filters, while a normal Where(x => x.Id == id) composes with them. One is a blind spot, one is not, and they look almost identical at the call site.

Know exactly which of your data-access methods honour your filters and which do not. The key-lookup and raw-fetch APIs are the usual culprits. If a global filter is load-bearing for isolation, ban the escape hatches on scoped entities, or lint for them.

The guarantee that evaporates off the request#

The filter reads the tenant from request-scoped state populated from the HTTP context. Which means in a scheduled job, a queue consumer, or startup code, there is no request, the scoped value defaults to the sentinel, and you get both failure modes from one root cause: a job that matches zero rows and silently does nothing, and a job that iterates unscoped and fans every tenant’s data out to every other tenant. I have watched both happen from the same missing scope.

Any ambient, request-lifecycle context (scoped injection, the HTTP context, an async-local) is simply absent in background work. Establish an explicit per-tenant scope there, or opt those paths out of the filter and pass the tenant key by hand, deliberately.

Enforce it once, and prove it with a test#

The fix that actually holds is not fixing each hole. It is moving the invariant to one central, reflection-driven place: a loop that applies the tenant filter to every mapped entity that carries a tenant id, so a new entity is covered the moment it exists. And then a test that enumerates the model and asserts every tenant-scoped entity actually has the filter, so a future unfiltered entity fails the build.

If an invariant can be forgotten one entity, one endpoint, one job at a time, it eventually will be. Enforce it in a place that cannot be forgotten, and write the test that fails when someone tries.

None of these are exotic vulnerabilities. They are all the same shape: isolation enforced by convention in most places, with a few paths that quietly skip the convention. The security background helps here, not because tenancy is exotic, but because the habit of asking “what reaches this without going through the check” is exactly the habit that finds all five.

Keep reading