Abstract
How SPA client routing, history state, deep links, and JS bundle route tables interact with real authorization boundaries.
Methodology
Literature review and controlled lab testing of common router patterns
Findings
Client route guards are presentation controls; bundles often leak privileged route inventories; history/query state can retain sensitive identifiers.
Impact
UI-only authz enables BOLA/IDOR and accelerates privileged-feature discovery.
Mitigation
Enforce server-side authorization on every sensitive operation; minimize privileged routes in public bundles; harden tokens and CSP.
Full analysis
Modern single-page applications (SPAs) move routing into the browser: the server often serves one shell document, and JavaScript decides which “page” to render from the URL. That architectural shift improves UX, but it routinely confuses teams about where authorization actually lives. This research note summarizes common SPA routing patterns, how they fail under a security review, and mitigations that hold up in production.
Methodology: literature review of common router designs (history API, hash routes, file-based meta routes), plus controlled lab observations on intentionally weak demos. Findings are qualitative — not a ranked CVE survey and not a claim about any specific vendor product.
Client-side route guards are not authorization
A typical SPA pattern:
- User requests
/app/admin/users. - CDN or origin returns
index.html+ JS bundles for all authenticated users (or even anonymously). - The router matches
/app/admin/usersand abeforeEach/ loader guard checksuser.role === 'admin'in memory. - If the check fails, the UI redirects to
/loginor/403.
From a product perspective this feels like security. From an assessment perspective it is presentation control. Anyone who can execute or rewrite client code — or call the underlying API directly — bypasses the guard.
Research takeaway: Treat client route guards as UX. Enforce authorization on every sensitive API and server action, keyed by session identity and object ownership — not by “did the router allow the component to mount.”
History state, deep links, and residual data
SPA routers store navigation state in:
- The URL path and query string (often logged by proxies, analytics, and Referer headers).
history.stateblobs (may hold IDs, filters, or even cached records).- Client stores (Redux, Vuex, Zustand) hydrated from prior views.
Deep links are valuable for users and dangerous when they encode capability:
| Pattern | Risk |
|---|---|
/orders/18422 without server-side ownership checks | IDOR / BOLA via parameter tampering |
/reset?token=… in query | Token leakage via Referer, browser history, screenshots |
state: { user: fullProfile } in history | Unexpected PII in Back/Forward cache behavior |
Preview routes like /internal/flags shipped in the bundle | Feature discovery for attackers |
Lab pattern: map all routes from the client bundle, then replay each parameterized route against the API with alternate IDs and roles. UI redirects are irrelevant if GET /api/orders/18422 returns data.
Client bundle leakage
Bundlers frequently emit:
- Route tables and path strings for lazy-loaded admin screens.
- Feature flags and environment labels (
staging,internalTools). - GraphQL operation names and REST path constants.
- Occasionally hardcoded preview credentials or debug endpoints (configuration smell).
Even without secrets, route inventories accelerate attacks. Knowing /admin/impersonate exists is enough to start authorization tests.
Enumeration tips (authorized labs):
- Search built assets for
/admin,role,permission,featureFlag. - Inspect source maps if accidentally deployed to production.
- Diff bundles across releases to spot new privileged surfaces.
Framework-specific footnotes (non-exhaustive)
| Style | Observation |
|---|---|
| Pure client routers (classic React Router / Vue Router) | All guards run in-browser; SSR optional |
| Meta-framework loaders (e.g. Remix/Next-style patterns) | Server loaders can enforce auth — when used consistently |
| Static-host SPA + separate API | Highest chance of “UI auth only” regressions |
Hash routing (#/admin) | Paths may avoid some server logs but still leak via client history |
The framework brand matters less than whether each data fetch re-checks authorization.
Failure modes observed in weak labs
- Guard on mount, fetch without authz — component redirects unauthenticated users, but the
useEffectfetch already fired with a stolen cookie or no cookie against a public misconfiguration. - Role in JWT displayed client-side only — UI hides buttons; API trusts a forgeable claim or ignores object-level checks.
- Optimistic UI exposing privileged actions — buttons render from cached role; server rejects late, but the action names and payloads taught the tester the API.
- Open redirects via returnUrl — login flows honor
?next=intojavascript:or off-site URLs when routers parse loosely. - Service worker / offline cache — privileged HTML fragments cached and later served to a different session on a shared browser profile.
Mitigations that actually move risk
- Server-side authorization on every sensitive operation — same checks for browser, mobile, and API clients.
- Do not ship privileged route modules to low-privilege users when feasible (separate bundles / gated SSR). Still never rely on absence of JS as authz.
- Minimize secrets and internal pathnames in public bundles; keep debug routers out of production builds.
- Prefer opaque tokens in paths; keep short-lived secrets out of query strings; use POST body or fragment carefully with full threat modeling.
- Clear or avoid storing PII in
history.state; rehydrate from the server on privileged screens. - Regression tests for BOLA on each new parameterized route — automated integration tests beat hoping the router guard is enough.
- Content Security Policy and strict script inventory reduce the blast radius of XSS that would otherwise rewrite client guards.
Mapping to SPA routing authz reviews
A practical review checklist:
- Extract client route list (manual or bundler artifact search).
- Classify routes: public, authenticated, privileged, parameterized.
- For each privileged/parameterized route, identify the API objects touched.
- Attempt cross-user and cross-tenant access at the API layer.
- Confirm error paths do not leak existence (user enumeration) unless product-accepted.
- Verify logout clears in-memory stores and does not leave privileged fragments in BFCache unexpectedly.
Limitations of this note
- No claim of statistical prevalence across the industry.
- Framework APIs change; always verify against your major version.
- SSR/edge auth patterns can be strong when implemented carefully — this note targets the failure mode where teams believe client routing equals access control.
