Skip to main content
Engagement composite · Multi-tenant SaaS

A valid user of one tenant, reading another tenant’s data

Authentication was never the weakness here. The token was signed, the session was live, the role was the right one. What the platform did not ask was whether the tenant behind that session owned the record the request named — and the same unasked question turned up again on an export path, and again at the point where a token is issued with a tenant written into it.

Sector
Multi-tenant SaaS
Coverage class
Web applications

The chain, step by step

4 steps, and what each one handed to the next

Each row names the technique, the weakness it maps to and what the step yielded. A finding that hands nothing to the next step is a finding, and belongs in the report rather than here.

StepWhat it yieldedSeverity
01 · A record identifier resolved without an owner check A confirmed read across the tenant boundary, and the identifier format every later step was built on. High
Two tenant accounts were provisioned for the test, so that a boundary existed to try to cross. A detail endpoint on the authenticated application took a record identifier from the request and resolved it against the whole table. The session check ran and passed; the role check ran and passed; neither of them was a tenant check. An identifier belonging to the second tenant, presented on the first tenant’s session, returned that record. — CWE-639, Authorisation bypass through user-controlled key.
02 · The same omission on a reporting export Records from outside the caller’s own tenant returned in one response, carrying the internal tenant identifiers the platform writes into its own rows. Critical
The reporting and export path accepted a filter over the same identifier family, composed its query from it, and applied the same absent ownership constraint. Where the detail endpoint resolved one record per request, the export resolved a result set — and it ran as a server-side job that wrote a file the caller collected afterwards, which put the missing constraint in a background worker as well as in a request handler. — CWE-284, Improper access control.
03 · Internal tenant identifiers inside the exported fields A tenant identifier in the form the platform’s own token claim is written in, which is the difference between the final step and guesswork. Medium
The export was built for operational use inside a tenant, so it carried the row metadata an operator would want: the internal tenant identifier, the owning account reference and the record timestamps. On its own that is a disclosure finding and would be reported and rated as one. In this chain it is the step that supplied a value in the exact internal form the next step needs. — CWE-200, Exposure of sensitive information to an unauthorised actor.
04 · A tenant claim trusted as requested A properly signed session that the application itself treated as belonging to the other tenant — its whole authenticated surface, rather than one endpoint at a time. Critical
The session token carried the tenant as a claim, and its signature was verified on every request. The gap was not in the signature. A tenant-switch call — the one a user with access to more than one tenant legitimately uses — accepted the requested tenant identifier as an input and minted a correctly signed token for it without resolving whether the authenticated subject was a member of that tenant. Supplied with the identifier recovered in the previous step, it returned a valid token whose claim named the other tenant. — CWE-287, Improper authentication.

01 · A record identifier resolved without an owner check

What it yielded
A confirmed read across the tenant boundary, and the identifier format every later step was built on.
Severity
High

Two tenant accounts were provisioned for the test, so that a boundary existed to try to cross. A detail endpoint on the authenticated application took a record identifier from the request and resolved it against the whole table. The session check ran and passed; the role check ran and passed; neither of them was a tenant check. An identifier belonging to the second tenant, presented on the first tenant’s session, returned that record. — CWE-639, Authorisation bypass through user-controlled key.

02 · The same omission on a reporting export

What it yielded
Records from outside the caller’s own tenant returned in one response, carrying the internal tenant identifiers the platform writes into its own rows.
Severity
Critical

The reporting and export path accepted a filter over the same identifier family, composed its query from it, and applied the same absent ownership constraint. Where the detail endpoint resolved one record per request, the export resolved a result set — and it ran as a server-side job that wrote a file the caller collected afterwards, which put the missing constraint in a background worker as well as in a request handler. — CWE-284, Improper access control.

03 · Internal tenant identifiers inside the exported fields

What it yielded
A tenant identifier in the form the platform’s own token claim is written in, which is the difference between the final step and guesswork.
Severity
Medium

The export was built for operational use inside a tenant, so it carried the row metadata an operator would want: the internal tenant identifier, the owning account reference and the record timestamps. On its own that is a disclosure finding and would be reported and rated as one. In this chain it is the step that supplied a value in the exact internal form the next step needs. — CWE-200, Exposure of sensitive information to an unauthorised actor.

04 · A tenant claim trusted as requested

What it yielded
A properly signed session that the application itself treated as belonging to the other tenant — its whole authenticated surface, rather than one endpoint at a time.
Severity
Critical

The session token carried the tenant as a claim, and its signature was verified on every request. The gap was not in the signature. A tenant-switch call — the one a user with access to more than one tenant legitimately uses — accepted the requested tenant identifier as an input and minted a correctly signed token for it without resolving whether the authenticated subject was a member of that tenant. Supplied with the identifier recovered in the previous step, it returned a valid token whose claim named the other tenant. — CWE-287, Improper authentication.

The environment

One codebase, one schema, and a column that decides who you are

A multi-tenant SaaS platform: every customer served by the same application against the same database, with the tenant carried as a field on the row rather than as a separate deployment.

In that architecture the tenant boundary is not a network boundary and it is not a database boundary. It is a predicate — a constraint on a query, and a claim in a token — and a predicate has to be re-asserted on every path that reaches data, including the paths nobody thinks of as paths. A request handler is one. A background export worker is another. The endpoint that issues a token is a third, and it is the one that decides what the other two are allowed to see. The scope for this engagement was the authenticated application, with a credential supplied for each role and two tenant accounts provisioned, because a boundary with nothing on the far side of it cannot be tested. That detail decides what kind of testing reaches this class of defect at all: nothing in the chain that follows is visible to a request that has not signed in, and nothing in it is visible to a session that has only ever seen its own tenant. Every step needs a valid, low-privilege, entirely ordinary user.

Points of view

The same request, read three ways

From no single vantage point does anything in this chain look abnormal, which is why authorisation defects survive monitoring that authentication defects do not.

01 Client

What the caller sent

The request
A signed-in user of one tenant asking for a record by its identifier, over their own session, holding the role the operation requires.
What is abnormal about it
Nothing on the wire. The identifier is a well-formed value of the right type, in the field it belongs in, at the endpoint it belongs at.
02 Server

What the application checked

Ran, and passed
Token signature, session validity, and the role the operation requires. All three were present on every endpoint reached during the engagement.
Never ran
Any question about whether the tenant behind the session owns the object the identifier names. The check was not failing. It was not there.
03 Audit

What the log recorded

The entry written
An authenticated user performing an operation they hold the role for, against a valid identifier, answered with a 200.
What is missing from it
The tenant of the object, beside the tenant of the caller. Until both sit on the same line there is no field to compare and nothing to alert on.

How it joined up

One question, unasked in three places

Read as a list, these are an access-control defect, a disclosure and an authentication defect. Read in order, they are one missing predicate, found three times over.

Triaged as separate tickets, these would be worked in severity order — and the step that decides whether there is a chain at all would be worked last. Step 01 establishes the premise: an identifier supplied by the caller reaches the query unqualified by tenant, which means the boundary is not being enforced at the data layer. Step 02 is that same premise on a path built to return a result set rather than a single record, and on a worker rather than a handler, which is where crossing the boundary stops being a demonstration and starts being an extraction. Step 03 is the hinge, and it is the lowest-rated row on the page. Internal identifiers inside an operational export are a moderate disclosure on their own terms, and on their own terms they would sit in a backlog behind two Criticals. They are not on their own here. The tenant-switch call in step 04 needs a tenant identifier in a specific internal form; a tester who cannot produce one is guessing at a value, and step 03 hands it over in the form the platform itself writes it. That is what the ordering is for. Not that the last step could not exist without the earlier ones — it is a defect either way — but that the route to it ran straight through the finding nobody would have prioritised.

The four checks

What the application asked, and what it never asked

Both halves of authentication were enforced on every path tested. The two questions that were not asked are the two that define a tenant boundary.

What the application asked, and what it never asked
StateWhat it meansWhat follows
Is the caller who the session says they are? The token signature was verified on every request, at every endpoint reached during the engagement. This check was correct and it stayed correct throughout the chain. Enforced
Does the caller hold the role this operation requires? Role checks were present and they passed. The caller genuinely held the role for every operation in the chain — inside their own tenant, which is the clause that was never evaluated. Enforced
Does the caller’s tenant own the object this request names? Not asked. The identifier reached the query as supplied and the query was not constrained by tenant: in the request handler at step 01, and again in the background export worker at step 02. Absent
Is the tenant in this token one the subject belongs to? Terminal Not asked at the moment the token was issued. The switch took the requested tenant as an input and signed it, which made every signature check downstream both correct and beside the point. Absent
Key
  • Checked by the application on every path tested
  • Not checked, and the chain is built on its absence
  • TerminalNo state follows this one

What the report carried

Five artefacts behind every finding, and what they held here

Every B-52 finding ships with a reproducible exploit artefact. On a tenant-isolation chain the part that gets read first is the pair of responses, because that is what settles the only argument anyone has about these findings — whether it is really cross-tenant.

ArtefactWhat it held on this chain
The request as sent The full request line and headers for each step, with the substituted value set against the original it replaced, so a reader can see that one field changed and nothing else did.
Host: app.tenant-one.example — the request that crosses the boundary differs from an ordinary one by a single field value, which is exactly why it has to be shown as sent rather than described in a sentence.
The response as returned The body as the server sent it, for both tenants, so the two can be read side by side. A record belonging to the other tenant is only demonstrable against the record that belongs to yours.
Steps to reproduce Ordered from a clean session: which credential, which tenant, which value to substitute, at which step. Written so the fix can be verified by whoever owns the endpoint, rather than only by whoever found it.
A CVSS v4.0 vector Per finding, with the vector printed rather than only the score, so a team that disagrees with a rating can see which metric they are disagreeing about. On this chain most of that argument lands on privileges required, because every step needs a valid low-privilege session.
A CWE Per finding — and the four here are deliberately not the same one. 639 and 284 are both authorisation failures, but they fail in different code, and one mapping across both would have hidden that the export worker needed a fix of its own.
The mapping is what makes the remediation below separable. A single CWE across all four rows produces a single ticket, and a single ticket would have closed the export path by fixing the request handler.

The request as sent

What it held on this chain
The full request line and headers for each step, with the substituted value set against the original it replaced, so a reader can see that one field changed and nothing else did.

Host: app.tenant-one.example — the request that crosses the boundary differs from an ordinary one by a single field value, which is exactly why it has to be shown as sent rather than described in a sentence.

The response as returned

What it held on this chain
The body as the server sent it, for both tenants, so the two can be read side by side. A record belonging to the other tenant is only demonstrable against the record that belongs to yours.

Steps to reproduce

What it held on this chain
Ordered from a clean session: which credential, which tenant, which value to substitute, at which step. Written so the fix can be verified by whoever owns the endpoint, rather than only by whoever found it.

A CVSS v4.0 vector

What it held on this chain
Per finding, with the vector printed rather than only the score, so a team that disagrees with a rating can see which metric they are disagreeing about. On this chain most of that argument lands on privileges required, because every step needs a valid low-privilege session.

A CWE

What it held on this chain
Per finding — and the four here are deliberately not the same one. 639 and 284 are both authorisation failures, but they fail in different code, and one mapping across both would have hidden that the export worker needed a fix of its own.

The mapping is what makes the remediation below separable. A single CWE across all four rows produces a single ticket, and a single ticket would have closed the export path by fixing the request handler.

The shape of it

What a cross-tenant read looks like on the wire

One field value, inside a request that is otherwise entirely legitimate.

The step 01 request is a GET against the record detail path of the authenticated application, carrying the first tenant’s session, with Host: app.tenant-one.example and a record identifier in the path. Replayed unchanged except for that identifier — swapped for one observed on the second tenant’s own session, in the second tenant’s own browser — it answers 200, with the second tenant’s record in the body. No error, no redirect, no additional prompt, nothing a client-side control would have caught. Step 02 is the same substitution moved into a query parameter, against a path that composes a filter rather than resolving a single row, and answered out of band by a file the job writes. Step 04 is a POST carrying a tenant identifier the caller holds no membership of, answered with a signed token that says otherwise. Each belongs in a report as a transcript rather than as a sentence, and the reason is practical: a reader with the codebase open can tell from the transcript which layer to open. The first two say the constraint is missing below the handler. The fourth says it is missing above it.

What changed

Four fixes, in the order they had to land

The order is not the order of severity. It is the order in which one fix stops being quietly undone by the absence of the next.

How it was confirmed

What the retest had to show, fix by fix

A fix that cannot be demonstrated failing before and passing after is a fix nobody can audit. Each row is the test that was run back against the artefact the original finding shipped with.

The changeWhat the retest ran
Tenant predicate in the data-access layer The step 01 and step 02 requests replayed from the original artefact, on the same two tenant accounts. A cross-tenant identifier now resolves to nothing; the same-tenant request is unchanged. Both halves matter — a change that breaks legitimate access is not a fix, it is an outage with a security note attached.
Export worker scope The export queued with the original parameters, and the job’s own output read rather than only the endpoint’s response.
This is the row most often skipped. The handler and the worker were separate code paths, and a retest against the handler alone would have signed off a background job that still had no constraint on it.
Membership check at token issue The switch called with a tenant identifier the subject holds no membership of, and refused; then called with one it does hold, and honoured. Both directions, because a check that refuses everything also passes the first half of that test.
Fields removed from the export The export re-run and its columns compared against the response recorded in the finding, confirming the internal identifiers are gone and that nothing an operator legitimately relies on went with them.
The chain, end to end The full sequence attempted again from step 01 on a clean session, to establish that the route is closed at more than one point rather than at whichever point turned out to be cheapest to change.

Tenant predicate in the data-access layer

What the retest ran
The step 01 and step 02 requests replayed from the original artefact, on the same two tenant accounts. A cross-tenant identifier now resolves to nothing; the same-tenant request is unchanged. Both halves matter — a change that breaks legitimate access is not a fix, it is an outage with a security note attached.

Export worker scope

What the retest ran
The export queued with the original parameters, and the job’s own output read rather than only the endpoint’s response.

This is the row most often skipped. The handler and the worker were separate code paths, and a retest against the handler alone would have signed off a background job that still had no constraint on it.

Membership check at token issue

What the retest ran
The switch called with a tenant identifier the subject holds no membership of, and refused; then called with one it does hold, and honoured. Both directions, because a check that refuses everything also passes the first half of that test.

Fields removed from the export

What the retest ran
The export re-run and its columns compared against the response recorded in the finding, confirming the internal identifiers are gone and that nothing an operator legitimately relies on went with them.

The chain, end to end

What the retest ran
The full sequence attempted again from step 01 on a clean session, to establish that the route is closed at more than one point rather than at whichever point turned out to be cheapest to change.

How it ran

Autonomous, expert verified — and what the verification added

The run underneath is the same run the fully autonomous model performs. What the delivery model changes is whose signature the report carries, and on an authorisation chain there is a specific thing the senior auditor’s pass is for.

The engagement, and where the autonomy boundary sat As of 2026-09-15
  • Coverage class: web application penetration testing, against the authenticated application, with a credential supplied for each role and two tenant accounts provisioned so that a boundary existed to test. Nothing was deployed inside the customer’s network — external and application testing needs none, and only internal network work does.
  • Scope was signed off before anything ran, and the export and tenant-switch paths were named in it. A test that may substitute identifiers between two tenants is a test that has to be authorised to do so in writing first, and that authorisation is what separates this from the thing it resembles.
  • Six phases, discovery through a quality gate that runs on the finished report and can send it back. The gate sits on the report, not on the run.
  • Under the autonomous expert-verified model a senior Security Brigade auditor verifies every finding before it reaches the customer. On this chain that step is what separated step 03 from the two rows either side of it: the platform had it found, mapped and rated as a disclosure, and what a reviewer adds is the judgement that it is load-bearing — that the row rated Medium is the row that makes the row rated Critical reachable without guesswork. A rating is a property of a finding. Being the hinge is a property of the set.
  • Security Brigade has been CERT-In empanelled since 2008 and is ISO 27001 certified. Those are the firm’s instruments; the B-52 platform holds none of its own.
  • B-52 and Security Brigade’s expert assessment team have worked the same targets in parallel, and of the pooled set of findings from both — one denominator, each item counted once — B-52 reached 90–95%. It is a proportion of that pooled set, not a coverage figure for this engagement, which has none of its own.

Deliberately excluded

  • Physical, hardware and wireless testing, which are out of scope for B-52 in every class.
  • Turnaround. The expert-verified model has no published turnaround figure, so none is stated.
  • Anything reachable only through a credential that was not supplied. Roles without a credential in scope were not tested, and the chain above is bounded by the roles that were.

Behind each of these

Where the rest of it is written down

Each card is a decision this engagement turned on, and the page on this site that documents how it is made every time rather than just this time.

Boundary

What the platform is allowed to do by itself

Reading across the boundary was in scope. Writing across it was not, and nothing was attempted that would have altered a record belonging to the second tenant. Where that line sits, and who moves it, is a decision made before a run rather than during one.

Proof

Why a finding arrives as a transcript

Request, response, steps to reproduce, a CVSS v4.0 vector and a CWE, on every finding in every class. The evidence table above is that standard applied to one chain; this is the standard itself.

Scope

Two tenants, authorised in writing, before anything ran

Cross-tenant testing needs both accounts named and both authorised, by someone entitled to authorise them. The template this site publishes is the form that conversation takes.

Method

The six phases this ran through

Discovery through to a quality gate that runs on the finished report and can send it back. The method is the same in all three delivery models; what changes is who signs the output.

Class

What web application testing covers

The authenticated surface, the roles behind it and the authorisation decisions between them — which is the half of a web application that an unauthenticated view of it cannot reach.

Cadence

Why a predicate wants re-testing after it lands

A tenancy constraint is the kind of thing a later refactor removes without anything visibly breaking: the query still runs and still returns rows, just more of them than it should. That failure mode is silent, so the check for it has to be repeated.

Pipeline

Catching the regression before it ships

B-52 runs in four continuous-integration systems in production — GitHub Actions, GitLab CI, Jenkins and Azure DevOps — and a result can fail a build against a severity threshold you set.

Run the same chain against your own tenant boundary

A scan is one application or one target, and the entry price is $500. A paid trial is $299. Web application testing is one of the five application classes the self-serve flow reaches; anything wider than a single target is a scoping call.