DOM-based XSS flows from source to sink entirely in client-side JavaScript - the server returns nothing injectable, which is why the same probes that confirm reflected and stored XSS silently miss it.
DOM-based XSS is the third variant in the XSS family, and the one most scanners miss cleanly. The server returns nothing injectable; the payload never touches an HTTP response in a recognisable form. The entire flow - from a user-controlled source to an execution sink - lives in client-side JavaScript, and the only way to observe it is to run the script and watch the DOM.
Reflected XSS injects into a server-generated HTTP response. Stored XSS reads back from a database. DOM XSS does neither. The source is a property the browser exposes directly to JavaScript - location.hash, location.search, document.referrer, window.name, or postMessage data - and the sink is a JavaScript API that writes to the document or evaluates a string as code. The server is a bystander; the browser does all the work.
Common sinks include innerHTML, outerHTML, document.write, document.writeln, eval, Function(), setTimeout and setInterval when called with a string argument, and DOM attribute setters such as element.src or element.href. Each carries a different severity: an innerHTML write from an unencoded source is code execution; an href assignment from the hash is typically an open-redirect, chainable but lower severity on its own.
A scanner that replays HTTP requests and inspects HTTP responses has the right tool for reflected and stored XSS. For DOM XSS the confirming evidence does not exist at the network layer. The injected value arrives via the fragment identifier or a client-side property and is processed entirely by the browser's JavaScript engine - no network round-trip carries the payload. The HTTP trace is identical whether the page is safe or vulnerable.
// Fragment is never sent to the server - the browser strips it before the request const query = decodeURIComponent(location.hash.slice(1)); // Sink: innerHTML without sanitization - DOM XSS document.getElementById('results').innerHTML = 'You searched for: ' + query; // Safe pattern: textContent or a sanitizer like DOMPurify document.getElementById('results').textContent = 'You searched for: ' + query; // or: DOMPurify.sanitize(query) before innerHTML assignment
Detection requires a headless browser that evaluates the JavaScript and instruments the sink functions. apPosture drives a headless Chromium instance, injects the candidate value into each observed source, and hooks the dangerous sink APIs before the page script runs. When a hooked sink receives a value that originated from an instrumented source, the taint path is confirmed client-side - without the payload ever appearing in an HTTP response the server generated.
The confirming oracle for DOM XSS follows the same proof discipline as the rest of the engine: demonstrate that evaluation happened, using a value that cannot cause harm if observed by a third party. The approach has two steps.
First, the source is set to a nonce-bearing string that is not a valid HTML tag and not a JavaScript expression: something like #_apNonce_62615533_. If the nonce appears inside a innerHTML assignment, the taint is confirmed - the value reached the sink, and an attacker could substitute a real <img onerror=...> payload. The nonce itself is harmless; it carries no script and no event handler.
Second, for eval and Function() sinks, a benign arithmetic oracle substitutes for the nonce: inject 7919*7907 as the source value and check whether the sink evaluates it to 62615533. A reflected-but-unevaluated string stays as the literal characters; a sink that calls eval on the source returns the computed result. The difference is in-band, binary, and non-destructive.
Severity follows the sink and the page context. An innerHTML sink on a page that is served only to unauthenticated users and holds no sensitive data is high; the same sink on an admin interface or an authenticated payment page is critical. An href or src assignment from a hash value is typically rated medium - open redirect, no immediate script execution - unless the page context lets a javascript: URI through, at which point it escalates.
The three XSS classes share the goal of executing script in a victim's browser but differ in how they get there. Understanding the differences matters for choosing the right probe, the right fix, and the right severity anchor.
| Property | Reflected XSS | Stored XSS | DOM XSS |
|---|---|---|---|
| Payload location | HTTP response from server | Database, then HTTP response | Client-side JS runtime only |
| Server sees payload | Yes - in the response | Yes - on retrieval | No - fragment/postMessage never reaches server |
| Detection method | HTTP response inspection | Second-session HTTP response | Headless browser sink instrumentation |
| Fix location | Server-side output encoding | Server-side output encoding | Client-side: textContent, DOMPurify, trusted types |
| CSP mitigates? | Yes, blocks inline script | Yes, blocks inline script | Partial - script-src helps; unsafe-eval undoes it |
A CSP that disables unsafe-inline and unsafe-eval substantially reduces the DOM XSS attack surface, but it does not close it. A nonce-based CSP that allows a specific inline script still allows DOM XSS originating from within that permitted script. Trusted Types is the browser primitive that actually gates sink assignment - requiring all values reaching dangerous sinks to pass through a named policy - and is the only control that prevents DOM XSS at the sink rather than the policy level.
The fix is the same regardless of which source the taint flows from: treat every value that reaches a DOM sink as untrusted and apply the right output encoding for the sink type. textContent instead of innerHTML is the mechanical fix for HTML sinks. DOMPurify is the practical fix when HTML markup must be preserved. Trusted Types is the architectural fix that makes the class structurally impossible to introduce by accident - and the one that lets an audit confirm the control is in place without reading every line of JavaScript.