Understanding the Attack Surface: What's Hiding in Your JS Bundle
Every modern single-page app ships its own map of the backend. Open the network tab, pull the bundled JavaScript, and you get a list of every route the frontend knows how to call — including the ones a given user was never supposed to see. Admin panels are usually built as "just another part of the single-page app," gated by hiding a nav link or wrapping a route in a client-side isAdmin check. That's a UI decision, not a security boundary. The JS still ships the admin routes to every authenticated user's browser, because bundlers don't know the difference between "a link we hid" and "a link we forbid."
That distinction only matters if the server enforces it. This write-up is about what happens when it doesn't — when the same /Admin/* controller that correctly rejects a non-admin user on some routes silently serves them full data on others, inside the same request session, with the same token.
The Vulnerability Class: Missing Access Control on Admin Endpoints
This isn't an IDOR (you're not manipulating an ID to read someone else's record) and it isn't a straightforward missing-auth-entirely bug (the server clearly has an admin check — it just doesn't apply it consistently). It's missing access control, OWASP Top 10 A01:2021 and CWE-862/CWE-285 territory: an endpoint that should require an elevated role and enforce that check server-side, but doesn't — for that route.
That inconsistency is exactly what makes missing access control easy to introduce and easy to miss in review, because the unprotected route usually sits right next to correctly-protected routes in the same controller. A code reviewer skimming the file sees [Authorize(Policy = "IsGlobalAdmin")] attributes scattered around and assumes access control is uniformly enforced. It rarely is — someone adds a new admin action, forgets the attribute, and it ships. Nothing about the request looks abnormal. No error is thrown. The access control check simply never runs, and the endpoint returns 200 OK with data it should have denied.
Discovering the Admin Endpoints
The target had a separate admin single-page app served from its own subdomain. Pulling the bundled JS for that admin app (same technique as inspecting any production main.[hash].js) surfaced the full internal API surface for the /Admin/* controller — dozens of routes, none of which require any special access to read the source. That's the point: the client-side code has to know every route it might call, so the bundle is effectively documentation for the backend, handed to anyone who loads the page.
Among the routes recovered from the bundle:
GET /Admin/Identities/me/Context
GET /Admin/Organizations
GET /Admin/Tenants/
GET /Admin/Books/
GET /Admin/Books/{id}
GET /Admin/Identities/IPSUsers/All
GET /Admin/Identities/OperationUsers/
GET /Admin/Modules
GET /Admin/Apps/All
GET /Admin/Apps/ClientSecret?appUid={appUid}
POST /Admin/Apps/ClientSecret/RotateExpiration
None of that is exploitable by itself — it's just a route list. The interesting question is what happens when you replay those routes with a token that the server itself considers non-admin.
Confirming the Bypass — Positive vs Negative Controls
Testing was done with a client-supplied, valid, low-privilege bearer token, explicitly confirmed by the client as a non-admin account. First step: ask the server what it thinks of this account.
GET /Admin/Identities/me/Context
Authorization: Bearer <redacted-low-priv-token>
200 OK
{
"UserId": REDACTED,
"UserUid": "REDACTED",
"IsAdmin": false,
"IsGlobalAdmin": false,
"OrganizationId": null,
"OrganizationKey": null
}
Good — the server correctly resolves this identity as non-admin. And several routes in the same controller correctly act on that:
GET /Admin/Organizations -> 403 Forbidden
GET /Admin/Tenants/ -> 403 Forbidden
GET /Admin/Books/ -> 403 Forbidden
GET /Admin/Identities/IPSUsers/All -> 403 Forbidden
GET /Admin/Identities/OperationUsers/ -> 403 Forbidden
That's the negative control, and it's the important part: this rules out a token-validity or session issue. The token is real, it's accepted, and the server is capable of rejecting it on admin-only actions. So when the next set of routes returns 200 instead of 403, it isn't because the check is broken everywhere — it's because it's simply missing on specific routes:
GET /Admin/Modules -> 200 OK (full module catalog)
GET /Admin/Apps/All -> 200 OK (full M2M app inventory, all tenants)
Same controller. Same token. Same request session. One set of routes enforces IsAdmin/IsGlobalAdmin; the other doesn't check at all.

/Admin/Modules — 200 OK, full module catalog, same non-admin bearer that gets 403'd elsewhere in the same controller.
Chaining the Endpoints: From Enumeration to Secret Disclosure
/Admin/Apps/All doesn't just leak that the endpoint is unprotected — it returns a full inventory of every registered OAuth Machine-to-Machine (M2M) application on the platform, unscoped to the caller's own tenant:
[
{
"AppId": "REDACTED",
"AppUId": "<app-uid-1>",
"Name": "REDACTED-Integration",
"AuthProviderClientId": "REDACTED@clients",
"TenantKey": "Tenant-B",
"AreaKey": "REDACTED",
"AssociatedBooks": [ ... ]
},
{ "...": "13 more, across 5 other tenants" }
]
The caller's own account belonged to one tenant. The returned inventory spanned five other, unrelated tenants — full cross-tenant enumeration, from a token that the server itself had just confirmed was neither an admin nor a global admin.

/Admin/Apps/All — 200 OK, full M2M app inventory across tenants, from the same non-admin bearer.
Each entry includes an AppUId. That value is exactly what the next endpoint wants:
GET /Admin/Apps/ClientSecret?appUid=<app-uid-from-above>
Authorization: Bearer <redacted-low-priv-token>
200 OK
{
"AppId": "REDACTED",
"AppUId": "<app-uid-1>",
"ClientSecret": "«live OAuth client_secret, plaintext, redacted for this write-up»",
"ExpirationDateTime": null
}

/Admin/Apps/ClientSecret?appUid=... — 200 OK, live client_secret in plaintext, no expiration set, chained straight off the AppUId from the previous response.
No ownership check. No tenant check. Any appUid obtained from /Admin/Apps/All — including apps belonging to tenants the caller has no relationship with — returns that application's live OAuth client_secret in plaintext, with no expiration set. Two unprotected GET requests, chained, and a non-admin account walks away with a working credential for a machine-to-machine integration it should never have known existed.
A third endpoint, POST /Admin/Apps/ClientSecret/RotateExpiration, was visible in the same bundle and appeared to share the same missing check — it was not invoked, to avoid disrupting a live integration's credentials mid-engagement. That's a standard pentest boundary, not a limitation of the bug: a state-changing admin action, reachable the same way, is generally worse than a read.
What Was Actually Exposed
Chaining just these two GETs produced, for a single non-admin session:
- A full cross-tenant inventory of registered M2M applications — names, Auth0
client_ids, tenant/book mappings, across every tenant on the platform, not just the caller's own. - A plaintext
client_secretfor any application in that inventory, retrievable on demand, with no expiration configured on at least one of them. - Enough to request a real M2M access token directly from the identity provider and act with whatever scopes that integration holds — not exercised in this test, per standard practice of stopping at credential retrieval rather than pivoting into another tenant's live systems, but several affected application names strongly suggested write-capable, data-bearing integrations (underwriting, data ingestion, spreadsheet/reporting APIs).
One more detail worth calling out: the JWT's role claims for this "non-admin" test account included several admin-sounding roles (Administrator among them), even though the server's own identity-resolution endpoint independently returned IsAdmin: false. That claim bloat wasn't the cause of the vulnerability — the server clearly re-validates server-side rather than trusting the token's role list — but it's exactly the kind of loose IdP role assignment that turns a contained bug into a much worse one the day someone does trust the claim instead of re-checking it.
Impact
- Any authenticated user, regardless of tenant or role, could pull every M2M integration's live secret. Not one record — the entire platform-wide credential set, from a single low-privilege session.
- The blast radius crosses tenant boundaries. The vulnerable account belonged to one tenant; the exposed secrets belonged to five others. This isn't "a user saw their own data they shouldn't have" — it's a completely unrelated customer's OAuth application, handed over to someone with no relationship to it whatsoever.
- The disclosed credentials are immediately usable, not theoretical. A
client_id/client_secretpair works directly against the identity provider's token endpoint. Whoever holds it can request an access token and act with whatever scopes that integration was granted — several affected application names strongly suggested write-capable, data-bearing integrations (underwriting, data ingestion, spreadsheet/reporting APIs). - The exposure doesn't close on its own. At least one disclosed secret had no expiration configured, so the window stays open until someone manually rotates it — potentially indefinitely.
Put together: a non-admin account, with a token the server itself confirmed was non-admin, walked away with working credentials for other companies' integrations on the same platform. That's full compromise of every M2M integration's credentials, platform-wide — not a scoped, single-tenant leak.
Why This Keeps Happening
The root cause here isn't exotic. It's authorization-check inconsistency within a single controller: some routes carry the [Authorize(Policy = "IsGlobalAdmin")]-equivalent filter, others don't, and nothing enforces that every route in an admin-prefixed controller gets it by default. That's a pattern, not a one-off typo — when one route in a namespace is missing a check, it's worth assuming siblings are too, and auditing the whole controller rather than patching the one route a scanner happened to flag.
It's also a reminder that a JS bundle isn't just a performance artifact — it's a route map handed to every visitor. Hiding an admin link from the nav bar stops casual users from clicking it. It does nothing to stop someone from reading the bundle, listing every /Admin/* path it references, and replaying them with whatever token they already have. The frontend is not, and was never, the access control layer.
Mitigation
The fix is simple to state, even if it's tedious to audit for: enforce authorization server-side, on every route, every time. The client never gets a vote in this — hiding a nav link or gating a route in the frontend router doesn't count, because the JS bundle already told anyone reading it that the route exists. The server has to independently check the caller's role on every single request, with no exceptions carved out for "internal" or "read-only" admin endpoints.
Concretely, that means the same authorization check that correctly guards Organizations, Tenants, and Books needs to sit in front of every route in that controller — enforced centrally (middleware or a controller-level policy), not copy-pasted onto each route by hand, since that's exactly the pattern that let this gap ship in the first place. And immediately: rotate every secret the vulnerable endpoints exposed, and treat all of it as compromised, not just the one pulled during testing.
Final Thoughts
Nothing about this attack chain required a novel technique. It required patience: read the bundle, list the routes, try each one, notice which ones return 200 when their neighbors return 403. That's the uncomfortable part of missing access control as a class — the barrier to finding it is almost entirely attention, not skill. The server already told the tester, on the very first request, that this account wasn't an admin. It just didn't act on that answer consistently for the next nine.
The broader lesson scales past this one platform: any time an admin surface is "just another route in the single-page app," the JS bundle becomes a checklist for anyone willing to read it, and every route on that checklist is only as safe as the authorization check actually attached to it — not the one implied by which nav item was hidden from view.