Two Instances, One Platform, the Same Broken Authorization Pattern

Systemic architectural vulnerabilities in multi-tenant SaaS deployments

Disclaimer: The SaaS platform name is withheld. What follows was found independently across two separately configured instances of the same underlying product, used by two different teams within the org, which is itself part of the finding: the same authorization gaps appearing in unrelated deployments points at the platform's architecture, not a one-off misconfiguration.

We ended up testing the same customer-support ticketing platform twice, for two unrelated business units running two separately branded, separately configured instances of it. We didn't expect much overlap. What we got was the same authorization pattern showing up in both: search and view endpoints that don't check whether you're allowed to see what you're asking for, and, in the second instance, an even more basic failure, a protected page whose content gets returned to the client before the server actually finishes deciding whether you're logged in at all.

Short version: starting from a single low-privilege support account, we could search for a common word and get back every matching ticket across the entire organization's ticket history, not just our own; open any ticket by guessing or incrementing its number; inject a working script into a ticket comment that fired in a real support agent's browser when they opened it; and, on the second instance, retrieve and interact with a fully protected ticket page while completely unauthenticated, just by intercepting the request before the login redirect finished.


Starting from what the UI claims versus what the API returns

The pattern that cracked this open, on both instances, was the same one we keep coming back to: compare what the screen shows you against what your own browser's network traffic actually contains. Searching a common term in the in-app search bar returned a short, sensibly scoped list on screen. The API response backing that same search, visible in a proxy, contained a much larger result set spanning tickets that had nothing to do with the account doing the searching. That's the seed the rest of the findings grew from.

Read access nobody scoped

Once ticket search returned everything, the ticket-detail endpoint made the problem concrete. The URL for a ticket is just /ticket/<number>, and nothing on the server checks whether the authenticated account has any relationship to that ticket. Incrementing or swapping the number in the address bar opened someone else's ticket, fully interactive, comment box and close-ticket button included, with no ownership or membership check anywhere in the request. This is Broken Object Level Authorization in its purest form: the object identifier is the only thing standing between "your data" and "anyone's data," and the server trusts whatever identifier the client hands it.

A second, quieter version of the same gap sat in the platform's public knowledge-base viewer. Clicking an article, plainly meant to be public-facing help content, triggered an API call that returned far more than the article body: ticket identifiers, creator and modifier names, and other backend metadata that a public help article has no reason to expose. None of that data is independently destructive, but it's a free enumeration feed: real account names and real internal ticket IDs, handed to anyone who clicks a help link, that feed directly into the ticket-access flaw above.

Read access nobody checked, extended to registration itself

Two smaller findings compound the same theme from the account-creation side. The registration flow performed no ownership check on the email address supplied, meaning anyone could stand up an account under an email they didn't control, no verification link, no confirmation step. The endpoint that checks whether an email is already registered, necessary for preventing duplicate signups, doubled as a clean user-enumeration oracle: query it enough times and you get a reliable yes/no on whether any given address has an account, which is exactly the kind of list an attacker wants before a password-spraying run or a targeted phishing pass.

A separate, still-unconfirmed thread worth flagging: editing a profile's email address to one already in use silently disabled the original account and appeared to begin linking it to the existing one, with no verification step in between. We didn't push this to full account takeover since doing so risked disrupting a real account, but the behavior alone, silent account disabling triggered by an unverified email change, is the kind of thing that deserves dedicated follow-up before anyone concludes it's benign.

From viewing to injecting

The most interesting escalation came from a detail easy to miss: the application's comment field sanitizes and neutralizes script tags, but only in the browser, before the request leaves the client. Capture that same request in a proxy, strip the sanitization the client already applied, and submit a raw script payload directly to the API, and the server stores it without complaint. The payload doesn't fire for the account that posted it, since that account already rendered a sanitized version, but it fires in full for anyone else who opens the same ticket, confirmed here against an actual support agent, who watched their own browser execute the payload and confirmed a side effect (silently reopening a ticket they'd just closed) they hadn't taken any action to cause. That's stored cross-site scripting with a real victim, hiding behind a client-side-only sanitizer that a proxy trivially bypasses.

When even authentication turns out to be a client-side courtesy

The second instance produced the finding that undercuts everything above having a login requirement at all. Requesting a protected ticket page while unauthenticated does get redirected to the login screen, eventually, but the server generates and sends the actual protected page content in the response before that redirect takes effect on the client. Intercepting that first response in a proxy, before the browser acts on the redirect, hands over a fully rendered, fully interactive protected page, comment submission and ticket closure included, to a request that was never authenticated at any point. Access control here isn't a server-side gate that refuses to process a request until it validates a session; it's a client-side instruction ("please go to the login page now") issued after the sensitive work already happened. Anyone capable of intercepting their own traffic gets to ignore that instruction.

Why this is one platform-level failure, not eight bugs

Every finding here traces back to one habit, repeated across every layer of the application: enforcement lives in the browser, not the server. The UI shows a scoped ticket list, but the API doesn't scope the underlying query. The UI sanitizes a comment before submission, but the API doesn't re-validate what actually arrives. The UI redirects you away from a page you shouldn't see, but the API had already handed you the page's contents by the time that redirect fires. And critically, this same habit showed up independently on two unrelated, separately configured instances of the product, run by different teams with no shared configuration between them. When the identical architectural gap reproduces across separate deployments you didn't configure the same way, that's not a tenant misconfiguration you can fix locally, it's evidence the underlying platform was built assuming the client can be trusted to enforce rules the server never actually checks.

Chained together, a single low-privilege, freely self-registered account (registration requires no verification, remember) can enumerate valid users, harvest ticket IDs through the public knowledge base, pull the full contents of any ticket in the org by number, and plant a script that executes in a real support agent's session the next time they open one of those tickets. And on the instance with the redirect-timing flaw, an attacker doesn't even need that first account. The floor isn't "authenticated user with too much access." The floor is "anyone who can intercept one HTTP response."

Root cause

Client-enforced authorization and client-enforced sanitization are the same mistake wearing two outfits: trusting a layer you don't control (the browser, running code the user can inspect and bypass at will) to be the boundary between "allowed" and "not allowed." A server that emits the sensitive payload and only afterward tells the browser to redirect somewhere else has, functionally, already lost the argument about whether the request was authorized.

Fix guidance

  • Scope every list and detail query server-side to the caller's actual permissions, before any client-supplied filter or ID is applied. Search results and direct ticket lookups both need this. There's real engineering cost to retrofitting scoped queries onto endpoints that were built to just return everything and let the frontend sort it out, but it's the only fix that survives someone opening a proxy.
  • Never generate or transmit a protected response body before the authorization check has completed and, if it fails, short-circuited the request entirely. A redirect instruction sent alongside sensitive content is not an access control, it's a suggestion. The fix is architectural: deny-and-halt before any protected data is serialized, not deny-and-hope-they-listen after.
  • Sanitize on the server, unconditionally, regardless of what the client already did. Client-side sanitization is a UX nicety for well-behaved browsers, not a security control; treat every request as if it arrived from a raw HTTP client with no JavaScript involved at all, because for an attacker, it did.
  • Require verified ownership before an email change or a new registration takes effect. Both the registration flow and the profile-email-change flow need a confirmation loop, and the "does this email exist" check needs a generic, timing-consistent response regardless of the answer.
  • Treat file upload as hostile by default: validate type by content and not by extension, store uploads outside any executable path, and serve user-supplied files from a separate origin with no script execution rights.

What other researchers should take from this

If you have access to more than one deployment of the same vendor product, whether that's two tenants, two business units, or two differently branded instances, test more than one. A finding that reproduces cleanly across separately configured instances is worth far more than a single instance's worth of findings, because it tells you whether you're looking at someone's misconfiguration or the vendor's actual architecture. And separately: any time you see a UI performing validation, sanitization, or scoping, assume it's decorative until you've confirmed the server enforces the identical rule against a request that skipped the browser entirely.

Appendix: Terms used in this post

  • IDOR (Insecure Direct Object Reference) / BOLA (Broken Object Level Authorization): Closely related terms for the same flaw: an application lets a user access or act on a record by referencing its ID directly, without verifying that user actually has permission to touch that specific record.
  • Stored XSS (Cross-Site Scripting): A vulnerability where an attacker's malicious script gets permanently saved on the server (in a comment, a post, a profile field) and then runs in the browser of anyone who later views that content, as opposed to reflected XSS, which only fires against the person who clicks a crafted link.
  • Client-side vs. server-side enforcement: Client-side enforcement means a rule (sanitizing input, hiding a button, redirecting away from a page) is implemented in the code running in the user's browser, which the user fully controls and can bypass. Server-side enforcement means the same rule is checked on the server, which an attacker cannot directly tamper with.
  • User enumeration: Determining which email addresses or usernames have real accounts on a system by abusing subtle differences in how the application responds to valid versus invalid identifiers, useful to an attacker as a target list for later password-guessing or phishing.
  • Non-repudiation: The security property that lets you prove a specific person took a specific action and prevents them from credibly denying it. A registration process with no ownership verification undermines this, since accounts can be created under emails the registrant doesn't control.
  • CVSS (Common Vulnerability Scoring System): A standardized 0-10 scale used to rate how severe a vulnerability is based on how easy it is to exploit and how much damage it can cause.
  • Race condition (in an authorization context): A flaw that arises from the timing between two steps that are supposed to happen in a specific order, here, between a server deciding a user isn't authorized and that decision actually taking effect on the client, leaving a window where the "before" state (the protected content) is still reachable.
  • Burp Suite / Proxy / Intercept / Repeater: A widely used web security testing tool. Proxy and Intercept let a tester capture and pause HTTP traffic between a browser and a server before it's sent or received; Repeater lets a tester resend a modified copy of a request to observe the server's response.
  • Tenant / multi-tenant SaaS: In software delivered to many customers from shared infrastructure, a "tenant" is one customer's isolated instance or configuration of the platform. Two tenants can be configured completely differently while running the exact same underlying code.