Cross-Site Scripting Exploitation in Modern Single-Page Applications
DOM-based XSS in single-page applications evades traditional server-side defenses entirely.

Modern single-page applications didn't get rid of cross-site scripting. They moved it client-side, opened up new DOM-based attack paths, and made the whole class of bug harder to catch with the tools most teams already lean on. Understanding how XSS actually plays out inside a React, Vue, or Angular app is the starting point for finding it and fixing it before someone else does.
The old model was simple. An attacker drops a payload into a form or a URL parameter, the server reflects it back inside an HTML response, and the browser runs it. The whole loop passes through the server, which means the server gets a chance to see it, log it, and maybe block it.
SPAs break that loop apart. Rendering happens on the client now, and in a lot of cases, the server never sees the payload at all. It just serves JSON, and the browser decides what to do with it.
Framework auto-escaping helps, to a point. React's JSX, Vue's template syntax, Angular's interpolation, these all escape output by default and stop the obvious cases. But that's the happy path, and the real danger sits in what's left over. Every framework ships an escape hatch for raw HTML, and those hatches don't get the same protection:
- React's
dangerouslySetInnerHTML, Vue'sv-html, Aurelia'sinnerhtml.bind - DOM sinks like
innerHTML,document.write,eval, orsetTimeoutfed a string argument - Third-party scripts running in production, analytics tags, chat widgets, ad code, all with full page privileges
- Two-way data binding that writes untrusted input straight to the DOM
- API responses rendered dynamically with no output encoding at all
Nearly every website today runs JavaScript client-side, and most developers list it as their main language. That's a huge attack surface, and the developers most at risk aren't the sloppy ones. They're the ones who trust the framework's defaults and never bother learning where those defaults quit working. That's the real failure mode here, misplaced confidence.
How stored, reflected, and DOM-based XSS each behave differently inside a SPA
Stored XSS still works the way it always has, mostly. A payload gets written to the server, sitting in a comment field, a profile bio, a blog post body, and then it fires for every single person who loads that page. No extra effort needed. It scales on its own.
A 2025 case in ERPNext (the Frappe framework) showed exactly this. An authenticated user injected malicious HTML and JavaScript into the Blog module's content field, affecting versions 15.67.0 and 15.72.4. The payload ran in the browser of anyone who viewed the post, opening the door to session hijacking and data theft. Frappe's slip isn't the point here. Even well-maintained, actively developed platforms get bitten the moment user-generated HTML renders without sanitizing it first, and that should worry anyone running similar code.
Reflected XSS looks different in a SPA. The payload lives in a URL or a request parameter, and it fires when a victim clicks a crafted link. Client-side routers make this worse in a specific way: they read fragment identifiers or query parameters and write them straight to the DOM, and the server never enters the picture. Server-side filters have nothing to filter, because nothing crosses the wire. Search results, error messages, and login redirect parameters are the usual spots where this shows up.
DOM-based XSS is the one that's purely client-side, start to finish. No request even reaches the server. The pattern is always the same shape: untrusted data comes in through a source, location.hash, location.search, document.referrer, a postMessage handler, and lands in a sink without escaping. A web application firewall is structurally blind here, because there's no HTTP traffic carrying the payload for it to inspect. That blindness makes DOM-based XSS especially difficult to defend against in modern web apps: server-side defenses don't reach it, full stop.
For a penetration tester, this creates a real split. Stored and reflected XSS sometimes turn up in an automated scan. DOM-based XSS in a SPA almost always needs a human to walk the client-side execution path by hand, tracing exactly how a value moves from source to sink.
And the stakes run higher than they used to, because of where SPAs keep credentials. Access tokens, ID tokens, OIDC session data, these can sit in sessionStorage or localStorage. Both are fully readable by any JavaScript running on the page. So a successful XSS doesn't need to guess a password. It just reads the token straight out of storage.
What a real DOM-based XSS exploitation chain looks like, step by step
A 2025 Equilibrium Security pen test case makes the mechanics concrete. The target was a modern SPA using OpenID Connect for authentication, the kind of setup that, on paper, ticks all the right boxes. OIDC access tokens and ID tokens were stored in sessionStorage, which is convenient (it clears when the tab closes) but still fully readable by any script running on the page.
The vulnerability itself came down to the Aurelia framework's innerhtml.bind feature, which the application used to drop user input directly into the DOM with zero sanitizing.
Here's how the chain played out:
- The tester entered crafted input into a field the app reflected back to the page.
- That payload ran in the browser, thanks to the unsanitized bind.
- The script reached into
sessionStorageand pulled the OIDC tokens. - Tokens got shipped off to a server the attacker controlled.
- The attacker logged in as the victim. No password required, anywhere in the process.
None of this depends on Aurelia specifically. Swap in any SPA that does two things at once, uses a raw HTML injection method somewhere and stores high-value tokens in browser storage, and the same chain plays out. The framework's name on the box doesn't matter.
Severity here isn't abstract. Token theft leads to full account takeover, and from there, the attacker gets everything the victim's role permits, for as long as the token stays valid. In a poorly configured app, that window can stretch out a lot longer than anyone intended. Once script execution happens, an attacker can leverage that foothold in any number of ways, from session hijacking to acting on the victim's behalf within the application.
That case only came to light through manual testing. Before going further, it's worth asking why the automated tools most companies already run walked right past it.
Why traditional defenses, WAFs, scanners, server-side filters, don't reliably catch SPA XSS
Web application firewalls inspect HTTP requests. A DOM-based XSS payload that lives entirely inside a fragment identifier, or gets assembled on the client from pieces that never travel together, simply never reaches the server in a form the WAF can look at. Betting on a WAF to catch DOM-based XSS is betting on a tool that structurally can't see the traffic in question.
Even where a WAF does see the traffic, there are hard limits on what it checks. AWS Managed Rules' CrossSiteScripting_BODY rule has historically only scanned the first 8KB of a request body. Pad the request with junk data ahead of the real payload, and the malicious part slides past the inspection window entirely.
Attackers have automated this. HTTP Parameter Pollution combined with JavaScript injection has produced bypass rates above 70% against AWS WAFs in documented testing, exploiting the gap between how the WAF parses duplicate parameters and how the backend actually handles them.
Automated scanners, Nessus, Qualys, Burp Suite running in automated mode, catch known vulnerability signatures fine in common setups. What they can't do is walk a SPA's client-side execution path, understand what the application's business logic is trying to do, or chain small findings into one real exploit. In the Equilibrium case, the Aurelia innerhtml.bind flaw required manual code review, tracing how input actually moved through the framework and into the DOM, to be identified.
Content Security Policy deserves a mention here too, because it's a mitigation rather than a cure. A well-configured CSP doesn't stop an underlying bug from existing, but it can raise the bar for exploitation considerably by restricting what injected scripts are permitted to do and where they can send data.
None of this makes WAFs or scanners worthless. They catch known patterns fine, and skipping them entirely would be its own mistake. But treat them as one layer among several, letting other layers decide whether SPA XSS gets caught. They miss anything specific to this app's logic, this app's context, or anything living entirely inside the browser, and that's exactly where the dangerous SPA bugs tend to sit.
How prototype pollution turns a JavaScript runtime quirk into an XSS bypass
Prototype pollution is a quirk of how JavaScript objects work, and it's a genuinely strange one once you see it. Every object in JavaScript inherits from Object.prototype. If an attacker manages to inject a property onto that prototype, the injected property becomes available on every object in the runtime, instantly and everywhere.
This usually happens through careless merging. Applications that combine user-supplied input into objects using Object.assign, jQuery's $.extend, or lodash's merge, without explicitly blocking dangerous keys like __proto__, leave the door wide open. The result ranges from odd, hard-to-explain bugs, to property overwrites, to, in the right conditions, arbitrary code execution or XSS.
A hypothetical but instructive example shows up in sanitizer bypass chains like the one described for a DOMPurify prototype pollution issue tracked as CVE-2026-41238. DOMPurify exposes a CUSTOM_ELEMENT_HANDLING configuration object. If a developer calls sanitize() without explicitly setting that option, DOMPurify falls back to reading it off the configuration's prototype chain. An attacker who has already pulled off a prototype pollution attack against Object.prototype can plant permissive regex values on those inherited properties. From that point on, every call to sanitize() in the app inherits the attacker's rules, DOMPurify starts accepting arbitrary custom element names and any attribute on them, event handlers like onload or onerror included. The sanitizer built specifically to stop XSS becomes the thing that waves it through.
This matters a lot for SPA teams specifically, because SPAs run on deep npm dependency trees. Any single package buried in that tree that does unsafe object merging on user input can hand an attacker the pollution primitive needed to kick off this whole chain. Burp Suite's DOM Invader feature automates a lot of the detection work for client-side prototype pollution and flags common pollution vectors, but finding the downstream XSS chain, proving it actually leads somewhere, still takes a person reasoning through the code.
Worth remembering too: prototype pollution isn't the only road from a trusted dependency to a working XSS. The Polyfill.io incident in June 2024, where a Chinese company acquired the widely trusted library and weaponized it, compromised over 380,000 hosts and injected malicious code into sites run by Hulu, Mercedes-Benz, and WarnerBros, among others. No pollution primitive needed. Just a supply chain link that everyone assumed was safe, until it wasn't.
How to systematically find XSS in a SPA during a penetration test
Start with the code, if it's available. In a whitebox engagement, source access lets a tester grep for every dangerous sink, dangerouslySetInnerHTML, v-html, innerhtml.bind, innerHTML, document.write, eval, before firing a single payload. That's coverage a blackbox test simply can't touch, because it maps the whole attack surface instead of poking at whatever's reachable from outside. Whitebox wins here, flatly, and any team skipping source review on a SPA engagement is leaving the DOM-based cases mostly to chance.
From there, all three XSS types need explicit testing beyond a general scan:
- Stored: submit payloads into every content field, comments, profile fields, rich-text editors, blog bodies, and confirm both persistence and execution across different user sessions.
- Reflected: fuzz URL parameters, query strings, fragment identifiers, and anything the client-side router reads, while proxying every request to see exactly what the SPA does with that input on the client.
- DOM-based: instrument the browser to trace source-to-sink data flows. DOM Invader automates a good chunk of this, but framework-specific binding patterns still need manual review.
Sanitization libraries need testing in their own right, verifying that they work rather than assuming it. If DOMPurify or something similar is in play, check that it's actually called correctly at each call site, and check the dependency tree for a prototype pollution path that could quietly override its configuration, the same pattern behind CVE-2026-41238.
Token storage deserves its own line item, because it sets the ceiling on impact. Confirm whether tokens sitting in sessionStorage or localStorage are reachable by an injected script. That single fact decides whether an XSS finding writes up as a full account takeover or something much smaller, and it should drive the severity rating in the final report.
WAF bypass potential is worth checking too, separately from whether the underlying XSS exists. Test whether payloads survive by probing known inspection limits, body size thresholds, HPP variants, encoded payloads. A WAF that looks like it's blocking something but can be routed around deserves its own line in the report, because that's a false sense of security waiting to fail.
Across all of this, the manual element isn't optional, and pretending otherwise is how findings get missed. The Equilibrium case and the Aurelia binding flaw both needed a person reasoning through the code, and that pattern holds across SPA engagements generally.ype pollution chain, the Aurelia binding flaw: all three needed a person reasoning about how the application actually behaved. Automated scanning alone would not have been sufficient to surface these findings.
And every finding needs a working exploit behind it, backing up the theoretical data flow. A demonstrated payload that achieves the claimed impact. Reports that go in front of auditors or customers don't have room for maybes.
Remediating XSS in a SPA: what actually works and what just moves the problem
The real fix is refusing to pass untrusted data to a dangerous sink in the first place. If dangerouslySetInnerHTML, v-html, or innerhtml.bind shows up anywhere in the codebase, the first question is whether it's needed at all. Most of the time it isn't, and swapping it for the framework's normal text-binding syntax closes the hole outright. That should be the default answer, avoiding sanitizing around a sink that never needed to exist.
Sometimes raw HTML rendering is genuinely necessary. Rich text editors and markdown output are the usual cases. When that's true, sanitize with DOMPurify or an equivalent library right at the call site, where the data can't get touched again before it renders. Check, too, that the sanitizer itself can't get bypassed through prototype pollution somewhere in the dependency tree, the exact failure mode behind the CVE-2026-41238 pattern.
Output encoding needs to match the context it lands in, every time. Store data raw, and encode it only at render time, based on where it's going:
- HTML content needs HTML entity encoding
- JavaScript context needs JavaScript string escaping
- URLs need URL encoding
- CSS needs CSS escaping
Getting the context wrong, encoding for HTML when the data actually lands inside a <script> tag, is one of the more common ways teams think they've fixed an XSS bug and haven't. That mismatch alone accounts for a lot of "fixed" bugs that come right back.
Prototype pollution needs its own audit line, separate from the sanitization work. Every object merge operation in the codebase, and in the dependency tree, needs review, with explicit checks that block __proto__, constructor, and prototype as keys during any merge of user-supplied data. Skip this step, and a clean sanitizer can still get quietly overridden later, the same way DOMPurify's configuration got hijacked in the chain above.
None of these fixes are exotic. They take real review time and real discipline, but they close the actual hole instead of stacking on another layer that a patient attacker just routes around. A WAF rule or a CSP header is a bandage. Removing the dangerous sink, or sanitizing correctly at the one place data enters the DOM, is the only move that actually ends the problem instead of relocating it.


