Engineering · Sep 7, 2026 · 7 min read

DOM-based XSS: when the sink lives in the browser

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.

01Sources and sinks: what makes DOM XSS distinct

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.

User input Browser JS runtime DOM location.hash ──► JavaScript source ──► innerHTML sink XSS fires location.search ──► url param read ──► document.write XSS fires postMessage ──► message event handler ──► eval / Function XSS fires document.cookie ──► cookie string parsed by JS ──► href assignment depends on value server never sees the payload
DOM XSS source-to-sink: the HTTP response is unchanged throughout.

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.

02Why server-side probes do not reach this class

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.

vulnerable-search.jsjavascript
// 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.

03Confirming execution without a real XSS payload

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.

Proven - HighDOM XSS - location.hash to innerHTML - /search
URL: /search#_apNonce_62615533_ ──► JS: document.getElementById('results').innerHTML = 'You searched for: _apNonce_62615533_' ──► DOM: <div id='results'>You searched for: _apNonce_62615533_</div> Nonce confirmed at innerHTML sink. Payload substitution: <img src=x onerror=...> would execute.
Oracle: nonce taint tracking via headless browser sink hook. Severity high - innerHTML sink reachable from unauthenticated source; escalates to critical if the vulnerable page is served with a stored session or loads on a page that processes privileged data.

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.

04DOM XSS vs reflected vs stored: the key differences

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.

PropertyReflected XSSStored XSSDOM XSS
Payload locationHTTP response from serverDatabase, then HTTP responseClient-side JS runtime only
Server sees payloadYes - in the responseYes - on retrievalNo - fragment/postMessage never reaches server
Detection methodHTTP response inspectionSecond-session HTTP responseHeadless browser sink instrumentation
Fix locationServer-side output encodingServer-side output encodingClient-side: textContent, DOMPurify, trusted types
CSP mitigates?Yes, blocks inline scriptYes, blocks inline scriptPartial - 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.

40+
dangerous DOM sink functions instrumented
0
server HTTP responses contain the payload
2
oracle types: nonce taint and arithmetic eval

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.

Evidence over heuristicsReachable beats foundMonitor → Block: rolling out a gate without slowing teamsIaC-grounded threat modelingAir-gapped AppSec with no phone-homeOne platform vs three tools
See it on your own app →