This is the finding we report most often against B2B SaaS, and the one that scanners structurally cannot produce. There is no signature for it, because the rule being broken exists only in your product: this row belongs to that customer.
The mechanics are almost always the same. An organisation identifier travels in the request — a path segment, a query parameter, a JSON field, a header — and the handler uses it to scope the query without checking that the authenticated user belongs to that organisation. The application works perfectly for every honest client, and returns any tenant's data to a dishonest one.
The Two Lines That Decide Whether You Have Isolation
Compare these:
// vulnerable: the tenant comes from the caller
const { orgId } = req.params;
const invoices = await db.invoice.findMany({ where: { orgId } });
// safe: the tenant comes from the session
const orgId = session.user.orgId;
const invoices = await db.invoice.findMany({ where: { orgId } });
Both are one line. Both look like scoping. The first is an authorisation flaw and the second is not, and no amount of input validation changes that — the value is a perfectly well-formed UUID, it is simply somebody else's.
The variant that survives review longest is the one where the check exists but is incomplete: the handler verifies the user is a member of some organisation, or verifies membership on the parent resource and then loads children by ID without re-checking. Nested resources are where this concentrates, because the parent check feels like it covered the request.
Where It Hides
Five places, in rough order of how often we find them.
Nested resources. /orgs/:orgId/projects/:projectId/files/:fileId — the org is checked, the project is assumed to belong to it, and the file is loaded by ID alone. An attacker keeps their own org and project in the URL and swaps only the file.
Exports and reports. Bulk endpoints are written later, by someone optimising a query, and often bypass the ORM scoping used elsewhere. A CSV export that takes a date range and an org ID from the request is the highest-yield version of this bug, because one call returns everything.
Background jobs and webhooks. Anything running with a service identity has no session to scope against, so the tenant necessarily comes from the payload. That is legitimate, and it means the payload has to be verified — a webhook handler that trusts an org ID in an unsigned body is an isolation bug with extra steps.
Search and autocomplete. Search indices are frequently built without a tenant field, or with one that the query layer forgets to filter on. The endpoint returns names and email addresses across customers, and because it returns fragments rather than records, it rarely gets tested.
Support and impersonation tooling. Internal admin surfaces are built to cross tenant boundaries by design. The question is whether crossing requires elevated authorisation and produces an audit trail, and in early-stage products the answer is often that any staff account can read anything, silently.
What It Means When It Fails
A single vulnerable endpoint is usually enough for full customer data exposure, because tenant identifiers are enumerable in practice: they leak in invitation emails, in export filenames, in webhook payloads, in support tickets, and in your own frontend when a user belongs to more than one organisation. An attacker does not need to guess UUIDs blindly if your product hands them out.
The regulatory consequence is worse than the technical one. Under GDPR this is unauthorised disclosure of personal data across controllers, and your contracts almost certainly promise logical separation between customers — which means one finding creates a notification question, a contractual question, and a trust question at once. Enterprise customers ask specifically about tenant isolation in security reviews for exactly this reason.
And unlike most vulnerability classes, exploitation leaves normal-looking logs. The requests are authenticated, well-formed and successful. Unless you log the relationship between the session tenant and the requested tenant, your own telemetry will not show it happened.
How to Test It Yourself
You need two accounts in two different organisations. That requirement is the whole reason this class goes untested: with one account, the test cannot be expressed.
Then walk your API surface with account A, replacing every identifier with one belonging to organisation B:
# A's session, B's invoice
curl -H "Authorization: Bearer $TOKEN_A" \
https://api.example.com/v1/invoices/$INVOICE_ID_B
# A's session, B's org in the path
curl -H "Authorization: Bearer $TOKEN_A" \
https://api.example.com/v1/orgs/$ORG_ID_B/members
# A's session, B's org in a body field
curl -X POST -H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' \
-d '{"orgId":"'$ORG_ID_B'","format":"csv"}' \
https://api.example.com/v1/reports/export
A 200 is a critical finding. A 404 is a pass only if it is a deliberate 404 — many applications return 404 for "not in your tenant", which is good practice, and others return 404 because the record genuinely was not found, which tells you nothing. Verify with a record you know exists.
Do it for every verb, not just GET. Update and delete paths are frequently less carefully scoped than reads, and a cross-tenant delete converts a confidentiality bug into an availability one.
The Fix, and Why the Obvious One Is Not Enough
Adding a membership check to the vulnerable handler fixes that handler. It does nothing for the next one somebody writes, which is why this class recurs in the same codebase for years.
What holds is making the tenant impossible to supply. Derive it from the session at the edge of the request and put it in a context object; make your data layer require that context, so a query without a tenant scope fails to compile or throws rather than returning everything. In Postgres, row-level security enforced with a per-request role gives you the same property at the database, which is stronger because it survives an ORM mistake.
Then close the paths that bypass all of it: bulk exports go through the same scoped layer, search indices carry a tenant field that the query builder always applies, background jobs verify the tenant in a signed payload rather than trusting it, and support tooling requires elevated authorisation and writes an audit entry naming the staff member and the tenant they crossed into.
Finally, test it continuously rather than annually. Tenant isolation breaks with ordinary feature work — a new endpoint, a new report, an optimisation that hand-writes a query — so the check belongs in the pipeline. That is the case for continuous testing in multi-tenant SaaS, and it is the single most valuable test you can automate.
Why Scanners Never Find This
Because there is nothing wrong with the request. It is authenticated, the identifier is valid, the response is a legitimate representation of a real record. A signature engine has no way to encode "this record belongs to a different customer" — that fact lives in your database and your business model, not in the HTTP exchange.
Which is also why a green scan report is not evidence of isolation. Passing every automated check while one customer can read another's invoices is not a contradiction; it is the expected outcome of testing tools that cannot hold two accounts. Anything that claims to cover this class has to authenticate as multiple tenants and attempt the crossing — human, or an agent doing what the human would.
