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.

playbookmulti-tenantaccess-controlsecurity-testingno-code
monito

Multi-tenant SaaS access control testing: proving Tenant A can't see Tenant B

playbookmulti-tenantaccess-controlsecurity-testing
August 5, 2026

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:

  1. 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.
  2. 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.
  3. The write paths, not just reads. OWASP is explicit that BOLA applies to POST/PUT/PATCH and DELETE too — being unable to see Tenant B's project is small comfort if you can delete it by ID.
  4. 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.
  5. 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.
  6. 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.

Go to https://staging.yourapp.com and log in as the Globex user
globex-user@example.com / Password123!

Acme (a DIFFERENT tenant) owns an invoice at the URL path
/invoices/ACME_INVOICE_ID. You do NOT own it.

Navigate directly to https://staging.yourapp.com/invoices/ACME_INVOICE_ID

Verify that the app REFUSES access: it should show a "not found"
or "you don't have access" state, or redirect you away — it must
NOT display Acme's invoice contents (amount, line items, customer
name).

If you can see ANY of Acme's invoice data, that is a critical
cross-tenant leak. Report exactly what was visible and the HTTP
status the page returned.

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.

Go to https://staging.yourapp.com and log in as
globex-user@example.com / Password123!

Open your own invoices list and watch the network requests to find
the API pattern the app uses to load an invoice — it will look like
GET /api/invoices/<id>.

Now, in the same authenticated session, request Acme's invoice
through that API by navigating to or fetching
https://staging.yourapp.com/api/invoices/ACME_INVOICE_ID

Verify the response is 403 or 404 with NO invoice data in the body.
A 200 response containing Acme's invoice fields is a broken-object-
level-authorization failure.

Report the status code and whether any Acme data appeared in the
response.

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.

Go to https://staging.yourapp.com and log in as
globex-user@example.com / Password123!

Acme owns a project at /projects/ACME_PROJECT_ID that you do not
own.

Attempt, in order, to:
1. Open Acme's project edit page directly by URL and change its
   name.
2. Trigger a delete on Acme's project by URL/ID.

Verify that EVERY attempt is refused — no edit is saved, no delete
succeeds, and Acme's project remains untouched. Then confirm as a
final check by describing whether the project still exists and is
unchanged.

Report any action that was NOT refused. A successful edit or delete
across tenants is a critical finding.

Prompt 4: role escalation inside a tenant

The intra-tenant version. A low-privilege user reaching a high-privilege surface.

Go to https://staging.yourapp.com and log in as a VIEWER-role user
viewer@acme.example.com / Password123! (this user has read-only
access within Acme).

Attempt to reach admin-only functionality directly:
1. Navigate to the billing/settings admin page by URL.
2. Attempt to invite a new user or change a role.
3. Attempt to hit any admin API endpoint you can identify from the
   admin UI's network calls.

Verify that each admin surface is refused for this viewer — the
pages should block access and the actions should not succeed.

Report any admin page that rendered or any admin action that went
through for this read-only user.

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:

Go to https://staging.yourapp.com and log in as the Globex user
globex-user@example.com / Password123!

A different tenant, Acme, owns an invoice at
/invoices/ACME_INVOICE_ID, which you do not own. Navigate directly
to that URL.

Confirm the app refuses access and shows none of Acme's invoice
data. Report the HTTP status and describe anything from Acme that
was visible, even briefly. Flag any cross-tenant data exposure as
critical.

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.

All Posts