Broken Access Control Patterns in Multi-Tenant SaaS Architectures
Tenant isolation failures in multi-tenant SaaS expose all customers when one boundary breaks.

Multi-tenant SaaS runs every customer on the same compute, the same database, the same queues, and the same caches. One logical boundary is all that separates tenant A's data from tenant B's, and that boundary has to hold at every layer, every time, with zero exceptions. This piece maps where that boundary actually breaks: the specific patterns, why black-box scanners walk right past them, and what it takes to find them before an attacker does.
The promise a SaaS platform makes to every customer is simple: you can't see anyone else's data, and no one can see yours. That promise is the entire product. Break it once, and you don't lose one customer's trust, you lose all of them, because the flaw that exposed tenant B's data will expose tenant C's and tenant D's the same way.
Here's why that's harder to guarantee than it sounds. Authorization has to work the same way on every API endpoint, every background job, every cache key, every call between services, not just the login screen and the dashboard. Miss one tenant filter in one query, forget the tenant ID in one cache key, or let one batch job run without tenant context, and you haven't leaked one account. You've potentially opened the door to all of them at once. That's the asymmetry that makes multi-tenant access control a different animal than classic broken access control. Traditional IDOR is user against user: I guess your account number, I see your invoice. Multi-tenant IDOR is company against company, and the blast radius scales with your customer count, not your attacker's effort.
OWASP has kept Broken Access Control at the top of its Top 10 since 2021 and again in 2025, and across the applications OWASP tested, 94% showed some form of access control weakness, spread across more than 318,000 recorded occurrences. That's not a niche bug class. That's the default failure mode of web applications, and multi-tenant SaaS inherits it with extra steps.
BOLA: how object-level authorization fails at the API layer
Broken Object Level Authorization means an API accepts a resource ID and hands back data without checking whether the tenant asking for it actually owns that resource. The exploit is almost embarrassingly plain: tenant A logs in, gets a valid session, then swaps the resource ID in their own request for one belonging to tenant B. If the server doesn't check ownership, it just returns tenant B's data. No exploit kit, no malware, nothing that looks abnormal in a log. It's a request that looks exactly like every other request, except the ID at the end points somewhere it shouldn't.
BOLA has sat at the top of the OWASP API Security Top 10 since that list started. Not because it takes skill to pull off, but because it's dead simple to introduce and easy to miss sitting in a code review.
Where does the check tend to go missing? A few recurring spots:
- Endpoints shipped fast during a feature sprint, where the developer assumed the ownership filter lived somewhere upstream
- Internal or "private" endpoints, where the team assumes tenants can't reach them, but a microservice boundary isn't a security boundary
- Bulk or export endpoints that loop over a set of records and apply tenant filtering to some of them but not all
- Write and delete endpoints on a resource where the matching read endpoint got the ownership check and the mutation path didn't
ORMs don't add tenant filters for you, and query builders don't know what a tenant is. That layer of authorization has to be written on purpose, every time, and it's exactly the kind of thing a busy engineer forgets under a deadline.
In a real assessment, a BOLA finding only counts once it's proven. That means logging in as tenant A, pulling a resource ID that belongs to tenant B (often just guessable or enumerable), and showing the actual data come back in a documented HTTP exchange. Anything short of that is a guess, not a finding.
IDOR across microservice boundaries and how tenant context gets dropped
Inside a microservice architecture, service-to-service calls often skip the strict authentication the front door gets. A gateway checks the JWT once, then internal calls pass around a user ID or a resource ID and quietly drop the tenant identifier along the way. The downstream service has no way to enforce a boundary it was never told existed.
This shows up in a few predictable places. A message published to a queue without a tenant field gets picked up by a worker that has no way to scope what it does. A batch job scheduled under tenant A's context runs later under a system identity, with no tenant predicate anywhere in its database query. The context that mattered at request time simply doesn't survive the trip.
The clearest real-world case of this: researcher Dirk-jan Mollema found a legacy Azure AD Graph API that accepted unsigned internal "Actor tokens" and never checked whether the token's tenant matched the tenant it was calling into. That gap allowed impersonation of any user, Global Administrators included, across any Entra ID tenant on the planet. It's tracked as CVE-2025-55241, CVSS 9.8, reported in July 2025. That's about as clean an example as you'll find of tenant context evaporating at a trust boundary that nobody thought to guard.
This is genuinely hard to catch without reading the source, because the problem doesn't live on the external API surface where a scanner or a tester can poke at it; it lives in the contract between internal services, invisible from outside. You can observe the symptom, maybe, if you're lucky enough to trigger it, but you can't see the mechanism. Even integration tests usually miss it, because most test suites run every service under one identity and never simulate real multi-tenant traffic bouncing between them.
The distinction from classic BOLA matters: this isn't a wrong ownership check, it's no ownership check, because the service on the receiving end never had the tenant information needed to perform one. Testing for it means mapping every inter-service call path, marking which ones carry tenant context and which ones just assume it's there, and then hammering the ones that assume it with resource IDs from a different tenant.
Tenant context leakage through shared infrastructure: caches, queues, and databases
Three pieces of shared plumbing leak tenant data without a single line of vulnerable application code. Caches like Redis or Memcached, or a CDN layer, will happily serve tenant A's cached response to tenant B if the cache key wasn't built with a tenant component in it. That's especially nasty for anything user-specific: profile data, entitlements, API responses baked into a cache hit.
Message queues and event streams (Kafka, SQS, RabbitMQ) carry the same risk. A consumer pulling messages off a shared queue without filtering on tenant ID will process records belonging to whoever happened to publish, not just the tenant that triggered the event. And shared database schemas often enforce row-level security unevenly: present on the read path, missing on the write or delete path, or absent entirely from a reporting query that joins across the whole table without a WHERE clause anyone remembered to add.
What makes this class strange is that no attacker has to do anything. A perfectly ordinary user, doing nothing wrong, can just get served someone else's cached data because their request happened to land on the wrong cache entry.
That timing dependency is exactly why cache leaks are so hard to catch in testing, since the leak only fires when tenant B's request lands on a cache entry tenant A's request populated. Black-box testing almost never produces that exact sequence across two separate accounts by accident. Finding it means reading the cache key construction logic directly, in source or in config, not poking at a live endpoint and hoping for a collision.
Background jobs are their own leakage vector too: a reporting job that builds a cross-tenant summary because its query never got a tenant ID clause, or a cleanup job that deletes the wrong tenant's records because it matched on something other than tenant ID.
The fix isn't complicated to state, even if it's tedious to enforce: tenant ID has to be part of every cache key, every queue message, every row predicate, full stop. And that has to be enforced at the ORM or query-builder layer, because leaving it to individual developers to remember on every single query is exactly how you end up with the leak in the first place.
Privilege escalation through shared roles, entitlements, and plan-tier logic
There are two separate escalation problems here, and they get confused constantly. Horizontal escalation is tenant A reaching tenant B's data, which we've already covered. Vertical escalation is different: a user inside a tenant reaching capabilities above their assigned role, or a tenant on a cheap plan reaching features gated to the enterprise tier.
Role escalation inside a tenant usually comes down to a handful of gaps: an admin-only endpoint that checks whether you're logged in but never checks whether you're actually an admin, a role check that exists in the UI and nowhere in the API behind it, or a role-assignment endpoint that lets a regular user promote themselves with a crafted request. Of everything found across multi-tenant assessments, "role enforced on the frontend, missing on the backend" is the single most consistent finding, across engagement after engagement.
Subscription-tier escalation runs the same playbook with money attached instead of permissions. Feature flags and plan entitlements stored client-side, in a JWT claim or a cookie or localStorage, and trusted without the server double-checking. An endpoint that gates a feature in the UI but applies no server-side plan check, so a free-tier user just calls the enterprise export endpoint directly and gets the data. Bulk or admin operations exposed at an endpoint that never asks whether the calling tenant's subscription actually includes that operation.
This isn't only a security bug, it's a revenue bug too. A free-tier customer quietly using paid features is the exact same flaw, exploited by a cost-conscious customer instead of an attacker, and it costs you money every day it goes unfixed.
There's a shared-infrastructure version of this too: when role and entitlement data lives in a shared identity service, and downstream services trust whatever that service hands back without validating it, a malformed or replayed token can carry elevated claims straight across a tenant boundary that was supposed to stop it cold.
Why these failure modes are structurally invisible to black-box testing and automated scanners
An automated scanner crawls the endpoints it can see, throws known payloads at them, and compares responses against known bad patterns. It has no idea what your data model looks like, no concept of a tenant graph, and no way to know that resource ID 4471 belongs to a company that isn't the one it's logged in as.
That's the core reason BOLA and tenant isolation bugs slide past scanners so consistently. A scanner works with one authenticated session; it has no second tenant account to impersonate, so it can't even attempt a cross-tenant test. And when the vulnerability does fire, the response is a clean HTTP 200 with well-formed, valid-looking JSON, not an error, not a stack trace, nothing a pattern-matcher flags as wrong. Finding the bug means knowing, in advance, which resource ID belongs to which tenant, and confirming the data that comes back is actually someone else's. A tool with one login can't do that.
Black-box testing runs into the same wall from a different angle. It sees the API surface and nothing behind it: it can't see whether tenant context survives a call between two internal services, because that handoff never touches the outside world. Cache key construction, queue message schemas, ORM query patterns: all invisible without reading the actual code or config. Same story with subscription enforcement; you find out whether a plan check exists in the business logic by reading the business logic, not by guessing which of forty endpoints might have skipped it.
Whitebox access is what actually opens this up. Reading the data model lets you find every table holding tenant-scoped data and check every query path against it for a missing predicate. Tracing the service call graph shows exactly where tenant context rides along and where it quietly gets dropped. Reading cache key logic and queue schemas directly beats trying to trigger a race condition in production and hoping it reproduces. Reading the authorization middleware shows you plan-tier enforcement, or the absence of it, without a single guess.
The numbers back this up. Across 119 SaaS penetration test assessments run in 2025, 755 vulnerabilities clustered in access control, sensitive data exposure, and business logic flaws, exactly the categories automated tooling alone reaches least effectively.
How to test multi-tenant isolation systematically: the methodology that finds what others miss
Before a single test runs, you need at least two fully isolated tenant accounts, at different roles, and, where the product has tiers, on different subscription plans. Testing with one account tells you nothing about tenant isolation, because tenant isolation is by definition a two-tenant problem.
For BOLA and cross-tenant IDOR, the method is mechanical but has to be thorough. Take every API endpoint you exercised under tenant A's session and systematically swap in resource IDs from tenant B, documenting every endpoint, method, and parameter that touches an object identifier along the way. Cover every verb, GET, POST, PUT, PATCH, DELETE, since write and delete paths lose their ownership checks far more often than reads do. Test bulk and export endpoints specifically; they tend to loop over records with weaker per-record authorization than a single-record endpoint gets.
For inter-service tenant context, start by mapping the actual service call graph from source or architecture docs, and note the authentication and tenant-identification method each internal call relies on. Where you can reach internal endpoints directly, bypassing the gateway, send requests that strip or forge the tenant context header and watch what comes back. Review the payloads flowing through queues and event buses for a tenant identifier field, and test what the consumer does when that field is missing or doesn't match.
For shared infrastructure, read the cache key construction for every cacheable response touching tenant-scoped data. Where you can, populate a cache entry as tenant A and try to pull it as tenant B by reconstructing the same key. Read the background job and batch processor code for a missing tenant predicate on the database queries they run.
For vertical escalation, call every admin-scoped endpoint directly using a non-admin session and flag anything that returns data or executes instead of throwing a 403. Call enterprise-tier feature endpoints with a free-tier session, both through the normal UI flow and straight against the API. If entitlement claims live in a client-side token, try forging or replaying one with elevated claims and watch how the server actually behaves.
Every finding needs a working exploit behind it: an actual HTTP exchange showing cross-tenant data come back, not a note saying a check looked like it might be missing. And this can't be a once-a-year event, since new endpoints and data model changes open new BOLA surface constantly, and catching that at the pull-request stage, rather than waiting for next year's assessment, is the only way testing keeps pace with a product that ships every week.


