Get started
Security & GovernanceMulti-tenancyIsolationEvidence

The multi-tenant leak that ships is a fallback, not an attack

When people picture a multi-tenant data leak they picture an attacker. The leak that actually reaches production, again and again, is duller than that: a default value, written by a careful engineer, doing exactly what it was told.

Adam Cowles30 September 20264 min read
A single luminous thread leaking out of one of several sealed cyan chambers into a shared violet pool, dark background

When people picture a multi-tenant data leak they picture an attacker. A clever request, a forged token, a path traversal. Those exist, and you should defend against them. But the leak that actually reaches production, again and again, is duller than that. It is a default value, written by a careful engineer, doing exactly what it was told.

Here are the two shapes it takes. If you build or buy multi-tenant software, you have met at least one of them.

Shape one: the org that falls back to "default"

A request that runs as a logged-in user knows its tenant. The user's session carries an organisation id, and the code reads it. Fine.

The trouble starts where there is no user. A background job. A scheduled task. A worker draining a queue. A completion hook that fires after the request has ended. None of these has a session to read, so the code that was written for the request path reaches for the tenant and finds nothing, and someone, sensibly, added a fallback so the job would not crash:

const orgId = context.orgId || 'default'

It looks harmless. It is not. Now every tenant's background work is tagged to one shared bucket called default. The lineage of who ran what, the summary the platform builds, the "learning" it crystallises from completed runs: all of it collapses into a single tenant that does not exist. One customer's completed work becomes visible in another's history, because as far as the store is concerned they are the same org. Nobody attacked anything. A default did its job.

The fix is not a bigger default. It is threading the real tenant through the paths that lost it: the job carries the originating run's organisation id, the hook receives the context that spawned it, and the genuine last-resort fallback is reserved for work that truly belongs to no tenant, and audited when it fires.

Shape two: the subquery nobody scoped

The second shape hides inside code that looks correct. The main query is scoped. The list endpoint filters by tenant. The reviewer checks the obvious line, sees WHERE org_id = ?, and moves on.

Then there is a second query the reviewer did not read. The "related items" join. The anomaly check that compares this record against similar ones. The sibling-record stitch that assembles a conversation from its parts. The aggregate that counts across a table. Any one of these, left unscoped, is a full leak, because it reaches across the whole table while the endpoint around it looks locked.

This is why "we scope by tenant" is not a claim you can accept at the endpoint level. Isolation is a property of every query and subquery that touches tenant data, not of the one at the top. The audit that matters enumerates all of them.

Why AI platforms feel this harder

Both shapes predate AI. What changes is the blast radius. An agent platform does not just store rows; it remembers, decides and acts on what it read. A mistagged completion does not sit quietly in a history table. It becomes context an agent reasons over, memory it writes, a decision it makes next time. The default bucket is not a reporting nuisance; it is a shared brain.

That is also why the honest platforms lean on a tamper-evident record. When work is witnessed on an append-only ledger as it happens, a mistag is not just a bug you hope someone notices. It is a discrepancy you can find, because the record of what ran, for whom, cannot be quietly rewritten to match the story. If you want to see how that ledger works, we wrote it up in the WARD witnessed ledger.

What to take from this

If you build: assume the leak will be a default, not an attacker. Grep your own code for || 'default' and its cousins, and for every query, ask what its subqueries do. Write the test that runs the background job with no context and asserts it does not touch another tenant.

If you buy: do not ask "are you multi-tenant". Everyone says yes. Ask what a job with no user in context runs as, and ask to see a read path with its subqueries scoped. Then ask them to prove the boundary live. We wrote a full guide to those questions and the live-proof standard in multi-tenant isolation for AI agents, and the standard we hold ourselves to for that live proof is on /security.

The leak that ships is boring. That is exactly why it ships. The teams that keep it out are the ones who treat the boring failure as the likely one, and prove the boundary rather than assert it.