Key takeaway: In a multi-tenant SaaS, cross-tenant data leaks happen because isolation lives in one place a developer can forget. The fix is defense in depth: tenant context comes from a verified token, every query is auto-scoped to that tenant, and the database itself enforces row-level isolation underneath.
A multi-tenant app rarely leaks data because someone wrote a malicious query. It leaks because a normal query, written by a tired developer on a Thursday, forgot one where tenantId = ... and nothing underneath it caught the omission.
That's the shape of almost every cross-tenant incident I've read about or found in an audit. Not a clever exploit. A missing filter, in a layer that trusted the next layer up to have already filtered.
Why one filter is never enough
If isolation lives in exactly one place, that place is one missed line away from leaking another tenant's data.
The common pattern in a fast-built multi-tenant SaaS is a single check sitting in the controller or the service. The developer reads tenantId off the request, passes it into the query, done. It works. Every endpoint that remembers to do it works.
The problem is the one that doesn't. A new endpoint added in a hurry. A repository method reused from a single-tenant context. A background job that runs without a user attached and queries the table directly. Any of those skip the filter, and there is nothing else in the stack saying no.
The other failure I see often is where the tenant id comes from. If the client sends it, in a header or a body field, you don't have tenant isolation, you have a request parameter the client controls. Change the value, read another tenant.
Three layers, because one developer will forget
The pattern that holds up has the token name the tenant, an interceptor scope every query, and the database enforce row isolation as the last line.
On Focalis, the multi-tenant platform we built around a client's 3D configurator, we put tenant isolation in three layers on purpose. Not because any one of them is fragile, but because together they fail closed.
- A guard reads the tenant from the verified JWT. The token is signed server-side at login, so the tenant claim isn't something the client can rewrite. No header, no query param, no body field is trusted for tenant identity. Ever.
- An interceptor auto-scopes every query to that tenant. A developer writing a new repository method doesn't have to remember the filter, because the filter is applied by the data-access layer before the query runs. Forgetting becomes hard rather than easy.
- The database enforces row-level isolation underneath. Row-level security policies on the tenant-scoped tables mean that even a query that somehow escaped the interceptor, run with the right tenant context, can only see rows that belong to that tenant.
Each layer alone is breakable. A guard can be bypassed by a route that forgot to apply it. An interceptor can be bypassed by code that uses a raw connection. RLS can be bypassed by a connection that runs as a privileged role. Stacked, the holes have to line up. They almost never do.
What each layer is actually doing
The token answers who, the interceptor answers what, and the database answers whether the row is allowed to be seen at all.
It's worth being precise about which question each layer answers, because the easy mistake is to repeat the same check three times instead of layering different ones.
| Layer | Question it answers | What it doesn't do |
|---|---|---|
| Verified token / guard | Which tenant is this request acting as? | It does not check what data the request touches |
| Query interceptor | Is every query scoped to that tenant? | It does not check the token, it trusts the layer above |
| Row-level security | Is this row visible to this tenant at all? | It does not know who the user is, only the tenant context set on the connection |
When each layer has its own job, a bug in one of them is contained by the others. When all three are doing the same check in slightly different words, you have one layer pretending to be three.
What we look for in an audit
The question we ask is: which single line of code, removed or forgotten, leaks another tenant's data?
When we read a multi-tenant codebase, the test isn't whether the happy path is filtered. It's whether the unhappy path is contained.
- Where does the tenant context come from on each request, and is that source signed server-side?
- Is there a path to the database that skips the query interceptor (a raw query, a migration script, a background job)?
- Are RLS policies on, and does the application connect with a role those policies actually apply to?
- Are there endpoints that take a tenant id from the client and use it without comparing it to the token?
- What happens in a background job that has no user, just a tenant? Where does that tenant come from?
None of this is exotic. It's the boring discipline of assuming any single check will, eventually, be skipped.
If you're about to onboard your second or tenth tenant onto a platform that was built for one, the isolation model is the decision that's expensive to get wrong later. That read is the whole point of an audit.
Frequently Asked Questions
What is a cross-tenant data leak?
It's when a logged-in user from one tenant reads data belonging to another tenant, usually because a query somewhere forgot the tenant filter and nothing underneath was enforcing the boundary.
Isn't one tenant filter on every query enough?
Not really. One layer is one place a developer can forget. The pattern that holds up is three layers: tenant context derived from a verified token, an interceptor that auto-scopes every query, and row-level security in the database as the last line.
Where should the tenant id come from on each request?
From a server-verified token, never from a header, query parameter, or body field the client can set. If the client can name the tenant, the client can name a different tenant.
Related Services
Need help with what you just read? These services are directly relevant.
