DOM-Based XSS Exploitation and Payload Delivery
Server-side defenses miss DOM-based XSS because the attack never leaves the browser.

DOM-based cross-site scripting doesn't need the server. The whole attack, from the malicious input to the moment JavaScript executes it, happens inside the victim's browser. The usual defenses built to watch traffic between browser and server are staring at an empty road while the crime happens somewhere they can't see.
DOM-Based XSS as a Fundamentally Different Class of Vulnerability
There are three kinds of XSS, and it helps to keep them straight. Reflected XSS bounces a payload off the server and back into the response. Stored XSS saves the payload somewhere (a database, a comment field) and serves it to whoever loads that page later. DOM-based XSS skips the server's involvement in the payload itself: client-side JavaScript reads untrusted data and writes it straight into the page.
That's the whole distinction, and it matters more than it sounds like it should. In DOM XSS, the HTTP response coming back from the server is identical when the attack is happening and when it is not. Nothing in the network traffic changes. The attack lives entirely in how the browser executes JavaScript that was already sitting on the page.
Following that fact to its conclusion makes the blind spot obvious. Server logs won't show it. A web application firewall won't show it. Backend monitoring tools built to flag suspicious requests have nothing to flag, because the payload never crosses the boundary those tools watch.
This gap is getting wider, not narrower. Single-page applications built on React, Vue, and Angular have pulled a huge amount of logic out of the server and into the browser. Every one of those frameworks reads data client-side and renders it client-side, creating more code paths where user input meets a dangerous DOM operation. As SPAs keep becoming the default architecture, DOM XSS remains a persistent and growing concern in real-world security testing.
How the source-to-sink model describes every DOM XSS attack
Every DOM XSS bug, no matter how it's delivered, reduces to two points connected by a line. Learn to spot both ends and the rest is just tracing.
A source is any input a browser gives JavaScript that an attacker can influence. The most common one by far is window.location, in particular the query string and the hash fragment. But sources also include things like document.referrer, window.name, and data pulled in through postMessage.
A sink is a JavaScript function or DOM property that treats its argument as something to execute or render, rather than plain text. innerHTML and document.write() interpret their input as HTML. eval() and Function() interpret input as code. Assigning to location.href with attacker data can trigger a javascript: URI. The sink is where trust gets misplaced.
Data flows from a source to a sink, unsanitized, and the browser does what the sink tells it to do with that data. No filtering server ever intervenes, because none is involved. This is why testing DOM XSS looks different from testing reflected or stored XSS. Instead of poking at request parameters and reading server responses, the work is reading JavaScript, finding where sources feed sinks, and confirming the gap between them.
URL Fragments and the Invisibility of Hash-Based Payloads to Server Defenses
Everything after the # in a URL is called the fragment, and browsers never send it to the server. Not in the request line, not in headers, nowhere. It exists purely as a piece of client-side state, which makes it close to a perfect delivery channel for a source-to-sink attack.
It plays out like this in practice. Some JavaScript on the page reads location.hash and passes the value into a sink, say innerHTML. An attacker builds a URL like http://target/page#<img src=x> and sends it to a target. The moment the victim opens that link, the browser parses the fragment, the vulnerable code inserts it into the DOM, and the onerror handler fires. Cookies, tokens, whatever's reachable, all of it can get shipped off to a listener the attacker controls.
If the vulnerable code transforms the hash value before using it, the payload may need to be adjusted accordingly to survive that processing step intact.
What makes this dangerous is not just the delivery mechanism. It is the privilege level. The injected script runs with the full authority of the page's origin. Cookies, DOM access, API tokens, session state, all of it is fair game, because as far as the browser's concerned, this is just code that belongs to the page.
Cookie flags help, but only with part of the problem. HttpOnly stops JavaScript from reading document.cookie directly, and Secure makes sure cookies only travel over HTTPS. Both are worth setting. Neither one stops the XSS from executing, and neither one stops an attacker from doing plenty of damage that has nothing to do with cookie theft, like manipulating the DOM, triggering unwanted API calls, or pivoting to other client-side attacks.
postMessage as a second delivery vector and its origin-validation failure modes
Fragments aren't the only way attacker data gets into a page without touching the server. postMessage is a browser API built for cross-origin communication between windows, tabs, and iframes, and it's become common in any SPA that embeds widgets or third-party content. If an application listens for postMessage events and renders whatever arrives, and it doesn't check who sent the message, any page that can get a reference to that window can send it a payload.
A typical setup: the attacker builds a page that opens the target application inside an iframe. Once the iframe loads, the attacker's script calls iframe.contentWindow.postMessage(payload, '*'). The victim never has to visit the vulnerable application directly, they just have to land on the attacker's page, and the malicious frame does the rest.
The failure almost always comes down to how origin checks get written, and there are four patterns by name:
- Substring match. Code checks
event.origin.indexOf('trusted.com') > -1. That passes fortrusted.com.attacker.com, since the substring is technically present. - startsWith check.
event.origin.startsWith('http://trusted')passes forhttp://trusted.attacker.com, because the string does start that way. - Unanchored regex.
/trusted\.com/.test(event.origin)matcheshttp://trusted.com.attacker.comjust as easily as it matches the real domain, since the pattern has no start or end anchor. - Null origin. Code that checks
event.origin === 'null'gets triggered by a sandboxed iframe usingsandbox="allow-scripts", which reports its origin as the literal string"null".
Each of these looks like a real check when read quickly in code review. Each one fails against a domain crafted specifically to slip past it.
Client-Side Routing and the Reintroduction of DOM XSS in Modern SPA Frameworks
React, Angular, and Vue all sanitize output by default, and that default does a lot of the heavy lifting most of the time. The trouble is developers know this, and knowing it breeds a kind of complacency: the assumption that XSS is "handled" by the framework, full stop. Every one of these frameworks ships an escape hatch for cases where a developer genuinely needs to render raw HTML, and that escape hatch is where DOM XSS comes back.
In React, it's dangerouslySetInnerHTML, and the name alone should be a warning label. Pair it with a route parameter pulled from useParams() or this.props.match.params, and a URL like /post/<img src=x> delivers the payload straight through the router into the sink.
In Angular, the sanitizer gets bypassed when a developer calls bypassSecurityTrustHtml() on data, and both route parameters and query strings commonly get passed through it, so both should be checked for this pattern.
In Vue, the v-html directive is the equivalent, often fed by this.$route.params or this.$route.query. The exact payload format depends on the app's routing mode, hash mode routing (#/route) or history mode (/route), so both need to be tested.
The pattern shared by all three is that developers reach for these escape hatches to render user-generated HTML, markdown previews, or rich text editors, and the use case feels completely legitimate. Nobody's trying to introduce a vulnerability. The missing sanitization step is just easy to lose in a code review that's focused on whether the feature works, not whether the input is trusted.
For anyone testing with access to the source, this is a real structural advantage. Grepping for dangerouslySetInnerHTML, bypassSecurityTrustHtml, and v-html finds candidate sinks directly, instead of blindly probing every route from the outside and hoping to stumble onto one.
Client-side sanitization bypass: DOMPurify misconfiguration, mutation XSS, and prototype pollution
DOMPurify is the library most applications reach for when they need to sanitize HTML on the client side, and it's good at what it does. Its presence on a page is not, by itself, proof of safety. There are three distinct ways it fails.
The first is misconfiguration. Overriding the library's defaults, such as whitelisting tags or attributes that should not be trusted, weakens its protections and opens a path around controls that would otherwise hold.
The second is mutation XSS, or mXSS, and it's the subtler of the two. A payload can look completely inert right after sanitization, clean, harmless-looking markup, and then get re-parsed by the browser's own HTML parser once it's inserted into the DOM. The sanitizer and the browser disagree about how a given string of HTML should be structured, and that disagreement is exactly where a hidden script can slip through.
The third is prototype pollution. If an attacker manages to pollute Object.prototype with properties that alter DOMPurify's internal configuration, the library can start skipping rules it would otherwise enforce, all without anyone touching DOMPurify's code directly.
None of this is theoretical. CVE-2025-26791 is a documented mutation XSS bypass against DOMPurify itself. Separately, CVE-2025-1647, named in a February 2026 update, covers a DOM clobbering XSS issue in Bootstrap 3: DOM clobbering works by overwriting JavaScript references with DOM element names, and that same technique can be turned against a sanitizer's internal logic to corrupt how it behaves.
WAF evasion and the limits of server-side defenses against the DOM XSS gap
Go back to the structural point from the start: fragments never reach the server, and postMessage payloads move browser to browser. Neither path ever enters a web application firewall's field of view, so there's nothing for a WAF to evade in those cases. The payload was simply never on a road the WAF was watching.
Even when a payload does travel through the server, WAFs miss it more often than security teams would like to admit. Traditional regex-based WAFs are well known for being bypassed with fragmentation and obfuscation tricks, breaking a payload into pieces or encoding it in ways the regex doesn't anticipate. AWS Managed Rules' CrossSiteScripting_BODY rule only inspects the first 8KB of a request body, and attackers who know that simply pad the request with junk data to push their payload past that 8KB window, out of inspection range.
Newer WAFs use semantic analysis and machine learning models in 2026, which raises the bar. But attackers have adapted right alongside them, crafting payloads that mimic normal traffic patterns or routing code fetches and data exfiltration through trusted third-party services, so the WAF sees what looks like legitimate outbound traffic instead of an attack.
Content Security Policy deserves a mention here too, because strict-dynamic has a real caveat. It works by trusting scripts that were loaded by other trusted scripts. That's efficient, but if any one of those trusted scripts, say an old jQuery build loaded under a valid nonce, has its own DOM XSS sink buried in it, the attacker inherits full execution through that trusted chain. The nonce did its job. It just trusted something that wasn't safe to trust.
The evasion techniques on the horizon go further still: payload fragmentation and reassembly across multiple requests, CSP nonce leakage through CSS injection and browser caching side channels, and AI-generated polymorphic payloads that rewrite their own structure to dodge pattern matching. Every one of these techniques assumes the same thing DOM XSS has assumed from the start, that the interesting part of the attack happens somewhere server-side defenses were never built to look.


