Multi-tenant SaaS access control testing: proving Tenant A can't see Tenant B
Authentication is not isolation. Multi-tenant SaaS access control testing means logging in as Tenant A and actively trying to reach Tenant B's data by URL and ID — then proving every attempt fails. Here's the playbook in plain-English prompts.
Multi-tenant SaaS access control testing: proving Tenant A can't see Tenant B
The scariest bug in a SaaS product isn't the one that crashes. It's the one where everything works perfectly — the page loads, the data renders, nobody sees an error — and the data belongs to a different customer.
Multi-tenant access control is the machinery that stops that from happening: the guarantee that when Acme Corp logs in, they see Acme's data and only Acme's data, even though Acme and Globex and two thousand other tenants share the same database and the same running process. It's also the machinery that almost never gets tested properly, because the happy path — log in, see your own stuff — exercises none of it. You only find out whether isolation holds when someone deliberately tries to break it.
This is the playbook for doing that testing yourself, before someone else does it for you. It's written as plain-English prompts you point at staging, and the whole approach rests on one move a scripted test almost never makes: logging in as one tenant and actively reaching for another tenant's resources.
Authentication is not isolation
The most important sentence in this whole topic comes from AWS's SaaS architecture guidance, and it's worth quoting exactly: "the fact that a tenant user is authenticated does not mean that your system has achieved isolation... a user could be authenticated and authorized, and still access the resources of another tenant. Nothing about authentication and authorization will necessarily block this access."
Read that twice, because it's the entire reason this class of bug survives to production. Your login works. Your role checks work — an editor can't do admin things, a viewer can't edit. Every test you wrote passes. And none of it says anything about whether an authenticated, authorized user of Tenant A can pull up Tenant B's invoice by changing a number in the URL. Isolation is a separate control from authentication, enforced separately, on every single data access — and separate controls need separate tests.
The vulnerability class has a name. OWASP calls it Broken Object Level Authorization: "Broken Object Level Authorization (BOLA) occurs when an API does not properly enforce authorization checks for each object accessed by the client. Attackers can manipulate object identifiers in API requests (such as IDs, GUIDs, or tokens) to access or modify resources they are not authorized to." In a multi-tenant product, "resources they are not authorized to" usually means another tenant's everything.
What "testing isolation" actually means
The test method is almost insultingly simple to describe and genuinely hard to do by hand at scale. OWASP spells it out: create resources in one account, then try to reach them from another. "With an account A, create resources that exclusively belongs to that account (e.g. purchase order) and with an account B, try to access the resource from account A." The tell is in the response: a properly secured system returns a 403 Forbidden or 404; "a 200 OK response for another user's object indicates BOLA."
Mapped onto a real multi-tenant app, a thorough check covers at least these surfaces:
- Direct object references in the URL. The invoice at
/invoices/1042, the project at/projects/88, the user at/settings/users/17. Change the number to one you don't own and see what comes back. - API endpoints behind the UI. The same objects are usually fetched by an XHR call like
GET /api/orders/1042. Sometimes the page guards the route but the API doesn't, so the browser is fine and a crafted request isn't. - The write paths, not just reads. OWASP is explicit that BOLA applies to
POST/PUT/PATCHandDELETEtoo — being unable to see Tenant B's project is small comfort if you can delete it by ID. - Cross-tenant identifiers in shared surfaces. Shared links, invite tokens, exported file URLs, embedded report links — anything that carries an ID and might outlive the permission that created it.
- Role escalation within a tenant. A viewer who can reach an admin-only URL, an editor who can hit the billing endpoint. This is the intra-tenant cousin and it ships just as often.
- The tenant switch itself. In products where one user belongs to multiple tenants, switching context has to fully re-scope every subsequent request — not just change a label in the header while the old tenant's data lingers in a cache.
A scripted end-to-end suite almost never covers these, and the reason is structural: the script was written by someone thinking about the intended flow, and isolation bugs live entirely in the unintended flow. You have to think like an attacker to write the assertion, and most test suites are written like a happy user.
The bug everyone ships once: the guard on the page, not the data
Before the prompts, the pattern to look for first: authorization enforced in the UI layer instead of the data layer.
It happens naturally. The frontend knows the current tenant, so it only ever requests the current tenant's objects, and it only ever renders links to them. Everything looks locked down because in normal use you can't even express the illegal request — there's no button for "show me Tenant B's invoice." So the backend developer, reasonably busy, trusts that the frontend will only ask for legal things, and skips the ownership check on the query.
Then someone types the URL directly, or replays the API call with a different ID, and the backend — which was never actually checking — happily returns the row. The guard was real, but it was in the wrong place: on the door the user can see, not on the vault. And you can only find it by making the request the UI would never make on its own, which is exactly what a browser agent can be told to do in one sentence.
If asserting on what the system does under adversarial input rather than on the intended path is a new framing, why AI QA agents find bugs your scripts miss lays out the general case.
Setup: two tenants, known data
Isolation testing needs a specific fixture: two separate tenants with distinct, identifiable data, and credentials for a user in each.
- Tenant A ("Acme") with a known resource — say an invoice, a project, and a document — each with an ID you record.
- Tenant B ("Globex") with its own distinct data and at least one user.
- A viewer-role and an admin-role user inside one tenant, for the role-escalation checks.
Create this on staging with real (non-production) data. Pass every credential through your Test Scenario config rather than writing it into the prompt body — the same secret-handling pattern the signup-flow playbook uses. The agent will log in as one tenant and deliberately try to reach the other's recorded IDs. Substitute your own URLs and IDs throughout.
Prompt 1: direct object reference by URL
The canonical IDOR probe. Log in as B, reach for A's object.
The instruction to report what was visible matters: a partial leak (the header renders before the permission check redirects) is still a leak, and it's the kind a pass/fail assertion would miss.
Prompt 2: the API behind the page
The page might guard the route while the underlying endpoint doesn't. Check the data layer directly.
Prompt 3: the write and delete paths
Reading someone else's data is bad. Mutating it is worse. Test that isolation holds on the dangerous verbs.
Prompt 4: role escalation inside a tenant
The intra-tenant version. A low-privilege user reaching a high-privilege surface.
Reading the results and making it permanent
Every failed Test Run comes back as a full Monito Session: the screenshot showing exactly what rendered, the network log with the offending status code and response body, and the agent's reasoning at each step. For isolation bugs that evidence is the report you hand to whoever has to fix it — a screenshot of Globex looking at Acme's invoice is not a finding anyone argues with.
These are the checks you least want to run only once. Save each prompt as a Test Scenario and wire the set into CI on every preview deploy, so the PR that quietly drops an ownership check on one endpoint fails before it merges. Each run is roughly 8–13 credits (about $0.08–$0.13), and because the prompts describe intent — "reach for data you don't own and confirm you're refused" — they keep working as your routes, IDs, and UI change underneath them.
One honest caveat: a browser agent tests isolation from the outside, the way a real attacker would, which is exactly the coverage most suites lack — but it doesn't replace defense in depth at the query layer or a proper security review of high-risk systems. Treat it as the continuous, every-deploy proof that the isolation you designed still holds, not as the only place isolation is enforced. The same spirit applies to any security-adjacent flow, like the account-boundary checks in testing the password reset flow.
The one to run first
Run the direct-URL probe first — it's the highest-severity, most common isolation failure, and it takes one scenario. Here's the version to paste in:
Your first run is free — point it at two staging tenants and find out whether "log in as A, ask for B" gets the answer it's supposed to.