Safeguard
Application Security

A methodology for testing SPAs for client-side vulnerabilities

DOM XSS, token storage, and API exposure don't show up in a server-side scan — here's a repeatable methodology for testing React, Vue, and Angular apps.

Safeguard Research Team
Research
6 min read

Single-page applications moved the trust boundary. A decade ago, most rendering happened on a server you controlled; today a React, Vue, or Angular bundle ships thousands of lines of logic straight into the browser, and a meaningful share of your attack surface now runs entirely on someone else's machine, under someone else's control. OWASP's Web Security Testing Guide formalizes this shift with a dedicated methodology for "Testing for DOM-based Cross-Site Scripting" — tracing untrusted sources like location.hash, document.referrer, and postMessage payloads to dangerous sinks like innerHTML, eval(), and document.write(). That source-to-sink model is the backbone of this post. It also explains why traditional server-side scanning misses so much: a request/response proxy never sees the DOM mutate after the page loads, never sees a postMessage listener fire, and never sees a JWT quietly sitting in localStorage where any injected script can read it. This piece lays out a repeatable methodology — DOM XSS tracing, token storage review, postMessage origin validation, and client-side API/authorization exposure — for testing SPAs built on any of the three major frameworks, and where each class of bug tends to hide in real framework code.

Where does DOM XSS actually hide in a React, Vue, or Angular app?

DOM XSS hides wherever a framework's built-in escaping gets deliberately bypassed. React auto-escapes JSX expressions, but dangerouslySetInnerHTML writes raw HTML straight into the DOM — the name is a warning label, not a safeguard. Vue's equivalent is the v-html directive; Angular's is [innerHTML] combined with bypassing DomSanitizer via bypassSecurityTrustHtml. A methodology for finding these bugs means grepping the bundle (or source) for those three patterns first, then tracing backward: does the value passed in ever originate from location.hash, a URL query parameter, document.referrer, or data returned from an API that itself echoes user input? OWASP's WSTG describes this exact source-to-sink trace as the core testing technique, and PortSwigger's public XSS documentation reinforces the same taxonomy of sources and sinks. Testers should pay particular attention to router libraries — React Router's useSearchParams, Vue Router's route.query — since URL-derived state is the most common attacker-controlled source in modern SPAs.

Why is a postMessage listener a common blind spot?

postMessage lets two browsing contexts — an iframe and its parent, or two windows — exchange data across origins, and SPAs use it constantly for embedded widgets, OAuth popups, and payment iframes. The flaw pattern is consistent: a listener registered with window.addEventListener( "message", handler ) that never checks event.origin before trusting event.data, or checks it with a loose comparison like .includes() instead of an exact match. Any page, on any origin, can then send that listener a crafted message. This class of bug is common enough that PortSwigger built a dedicated tool, DOM Invader, into Burp Suite specifically to fuzz and replay web messages against listeners and flag missing origin checks. A methodology here is straightforward: enumerate every addEventListener( "message", ... ) call in the bundle, confirm each one validates event.origin against an exact allowlist (not a substring match), and confirm the received data isn't passed directly into innerHTML or eval() without further validation.

Where should SPA session tokens actually be stored?

OWASP's guidance is explicit: don't store session tokens or JWTs in localStorage or sessionStorage, because both are readable by any JavaScript running on the page — including an attacker's script the moment any XSS bug fires anywhere in the app. httpOnly cookies close that specific hole, since script running in the page cannot read a cookie flagged httpOnly via document.cookie, even under active XSS. Pairing httpOnly with Secure (HTTPS-only transmission) and SameSite=Strict or Lax (restricting cross-site sending) is the combination OWASP and identity vendors like Descope recommend as the baseline for SPA session handling. A testing methodology should check response headers on the auth endpoint for all three flags, not just confirm a token exists somewhere.

Does moving tokens to cookies eliminate the risk?

No — and a methodology that treats "use httpOnly cookies" as a complete fix will miss real residual risk. An XSS payload that can't read a cookie can still ride the session: it can fire an authenticated fetch() request from inside the victim's browser, where the cookie is attached automatically, and exfiltrate or mutate data without ever touching the token itself — a CSRF-adjacent path that a strict SameSite policy narrows but doesn't always close for same-site subdomains or GET-based state changes. Cookies also carry a roughly 4KB size ceiling per cookie, which becomes a real architectural constraint for larger JWTs carrying multiple claims or roles, sometimes pushing teams toward split tokens or reference tokens instead. Testing should confirm CSRF protections exist independently of cookie flags, and should flag oversized JWTs before they hit that 4KB wall in production.

What does "client-side API exposure" mean, and how do you test for it?

Bundled SPA JavaScript routinely reveals more of the backend than teams realize: hardcoded internal API endpoints, feature-flag names, and — most seriously — authorization logic implemented only in client-side conditionals. A role === "admin" check that hides a UI button is not an access control; it's cosmetic, and the underlying API endpoint is reachable by anyone who calls it directly with a valid but lower-privileged token. OWASP's WSTG scopes this explicitly as part of client-side testing, distinct from DOM XSS. The methodology is to unminify and search the production bundle for endpoint strings, role names, and conditional rendering logic, then independently call every discovered endpoint with a non-privileged session to confirm the server — not the client — enforces the authorization decision. Any endpoint that only the UI, not the API, was gating should be treated as a critical finding regardless of how the frontend hides it visually.

How Safeguard helps

Client-side risk in SPAs lives in the same dependency and build supply chain Safeguard already tracks for backend services — a vulnerable frontend package pulled from npm carries the same reachability question as a vulnerable Python or Java dependency: is the flagged code path actually bundled and shipped to the browser, or dead weight that never executes? Safeguard's reachability analysis and SCA pipeline apply that same call-graph reasoning to JavaScript and TypeScript dependencies, so a CVE in a transitive npm package only ranks urgent when your build actually pulls it into the production bundle. Griffin AI then explains the finding in plain language and can generate a fix pull request, so frontend teams spend their time on the DOM XSS and token-storage review this methodology calls for, not on triaging noise from every package in a package-lock.json.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.