DOM-based XSS is the cross-site scripting variant that never reaches the server. The payload travels in a fragment, a postMessage event, a value read from localStorage, or a header the browser exposes to JavaScript; the application's own client-side code reads that source and writes it into the DOM through a sink that parses HTML or executes code. The server logs see a clean GET. The WAF sees nothing worth alerting on. The whole exploit happens between two lines of JavaScript running in the victim's browser.
I treat it as the most-missed variant in code review for that reason. The server-side mental model of "untrusted input crosses a trust boundary on the way in" does not apply, because the trust boundary is inside the browser. Every defence that lives upstream of the renderer is irrelevant.
In short: what is DOM-based XSS?
DOM-based XSS is a cross-site scripting variant where the entire data flow from attacker-controlled source to executed code happens in client-side JavaScript. The application's own scripts read a value from a source the attacker can influence (location.hash, location.search, document.referrer, window.name, a postMessage payload, localStorage) and pass it to a sink that interprets strings as code or markup (innerHTML, outerHTML, insertAdjacentHTML, document.write, eval, the Function constructor, setTimeout with a string, jQuery's $(htmlString)). The fragment form is the canonical example: the URL https://example.com/#<img src=x onerror=alert(1)> never sends the fragment to the server, so server logs only show GET / and a WAF in front of the application sees nothing. The single-page-app shift since 2015 has made this the dominant XSS class in modern code, because the server is mostly a JSON API and the rendering layer lives in JavaScript.
What makes XSS "DOM-based"?
The classification is by data flow, not by syntax. The defining property is that the source and the sink are both in client-side JavaScript: the malicious bytes are read by JS and written by JS, with the server out of the loop.
Compare against the other two variants. In reflected XSS, the source is a request parameter, the server echoes it into the response body, and the browser parses it as HTML on render. In stored XSS, the source is the database, the server reads it back and emits it into a response. In DOM XSS, the server never sees the payload, or sees it but does not reflect it; the dangerous transition from string to code happens entirely inside a script the application itself shipped.
That distinction matters in three operational ways:
- Server-side logs do not show the payload. A URL fragment (everything after
#) is never sent by the browser in the HTTP request line. Other DOM sources (postMessagedata,localStoragereads) also never appear in server logs. - WAFs cannot see the payload. A WAF inspects the HTTP request and response. Sources it cannot read (fragments,
postMessagepayloads, browser-internal state) are invisible to it. Even for query-string sources, the WAF cannot tell from the request shape whether the value will be reflected or read by client JS. - Server-side templating defences do not apply. Auto-escaping in Jinja, Twig, Blade, JSX-on-the-server, ERB, none of them touch a value that was never on the server.
Sources and sinks reference
The full surface, with the sources on the left (where attacker-controlled data enters client-side JS) and the sinks on the right (where a string becomes code or markup). Any pairing of source and sink is a potential DOM XSS.
| Source (where the data comes from) | Sink (where it gets executed) | Example |
|---|---|---|
location.hash, location.search, location.pathname | element.innerHTML = ... | el.innerHTML = location.hash.slice(1) |
document.referrer | document.write(...) | document.write('Came from: ' + document.referrer) |
window.name | element.outerHTML = ... | el.outerHTML = window.name |
postMessage event data | eval(...) | eval(event.data) (inside a message handler) |
localStorage.getItem(...) | setTimeout(stringArg, ...) | setTimeout(localStorage.getItem('cb'), 0) |
sessionStorage.getItem(...) | new Function(stringArg) | new Function(sessionStorage.getItem('h'))() |
document.cookie (cookies set by other origin scripts) | insertAdjacentHTML(...) | el.insertAdjacentHTML('beforeend', cookieVal) |
| WebSocket message data | $(htmlString) (jQuery) | $(socket.data) parses HTML when string starts with < |
IndexedDB read | element.srcdoc = ... (iframe) | iframe.srcdoc = stored |
<a href> interpolated from any source above | navigation to javascript: URL | link.href = userUrl then click |
Second axis: any DOM property that takes a URL and accepts the javascript: scheme is also a sink. That includes <a href>, <iframe src>, <form action>, and window.location assignments. A javascript:alert(1) URL is a working XSS payload as soon as the browser navigates to it.
Lab walkthrough
The companion lab at techearl-labs/cross-site-scripting/xss-basic ships the canonical DOM XSS sink in share.php:
<div id="target"></div>
<script>
document.getElementById('target').innerHTML = decodeURIComponent(location.hash.slice(1));
</script>Two interesting properties in those three lines. The fragment (#...) is read after the # is stripped, decoded once, and assigned to innerHTML. The whole pipeline is client-side. The exploit is the URL itself:
http://localhost:8081/share.php#<img src=x onerror=alert(1)>
A <script> tag in that position will not run. The HTML spec requires the parser to skip script execution for content inserted via innerHTML (see the HTML Living Standard's "script" insertion rules). To get JS to execute, use a tag that triggers script via an event handler attribute: <img onerror>, <svg onload>, <iframe srcdoc>, <input autofocus onfocus>, <body onpageshow> injected into a context that uses it.
What does the server see? The Apache access log entry for the exploit request:
127.0.0.1 - - [26/May/2026:12:00:00 +0000] "GET /share.php HTTP/1.1" 200 423 "-" "Mozilla/5.0 ..."
The fragment is not there. Browsers strip the fragment before sending the request line. The same exploit logged at any reverse proxy or WAF looks identical to a benign page load. The only place the payload exists in machine-readable form is the victim's URL bar and the browser's in-memory DOM.
Why this hurts more than people expect
The operational gap between "we have monitoring for XSS attempts" and "we have monitoring for DOM XSS attempts" is wide. A standard detection rule that flags <script>, onerror, onload, javascript: in query strings does nothing for fragment-based DOM XSS, because the fragment is not in the request. Even when the source is a query string, the rule fires only on probing scans, not on a successful exploit being delivered to a victim (the victim's browser already has the payload by the time the request lands).
Single-page apps with hash-based routing (/#/profile/123, the older React-Router hashHistory pattern, Vue's hash mode) are the canonical modern target because the routing logic itself is the dangerous read. Add to that the rise of postMessage between iframes and the use of localStorage for cached UI state that gets written back into the page on next load, and DOM XSS is now the variant I find most often in code review of anything written in the last five years.
Trusted Types: the structural defence
Trusted Types is a browser API that closes the DOM XSS class structurally rather than by sanitisation. It does this by making the dangerous sinks (innerHTML, outerHTML, document.write, insertAdjacentHTML, eval, setTimeout/setInterval with string arguments, Function constructor, srcdoc, script src) refuse to accept plain strings. Instead, they accept only typed objects (TrustedHTML, TrustedScript, TrustedScriptURL) that can only be created by a named policy the application registered with the browser.
Enable enforcement via a CSP directive:
Content-Security-Policy: require-trusted-types-for 'script'; trusted-types default;
Then in the application, every assignment to a covered sink has to route through a registered policy:
const policy = trustedTypes.createPolicy('default', {
createHTML: (input) => DOMPurify.sanitize(input, { RETURN_TRUSTED_TYPE: true }),
});
// Now this throws a TypeError:
element.innerHTML = userInput;
// And this works:
element.innerHTML = policy.createHTML(userInput);The point is not that the policy is itself a perfect sanitiser (it might be, if it wraps DOMPurify; it might not, if someone wrote a regex). The point is that the sink can only be reached through a single, named, auditable code path. The hundreds of incidental innerHTML = somevar assignments scattered across a real codebase all become hard errors. You either route them through the policy or you rewrite them to use a safe API.
Browser support, as of 2026: Trusted Types is Baseline 2026 (newly available across the current stable releases of every major engine). Chromium shipped it in Chrome 83 (May 2020), Safari turned it on in Safari 18.2 (December 2024), and Firefox enabled it by default in Firefox 134 (January 2025). The CSP require-trusted-types-for 'script' directive is now enforceable across Chrome, Edge, Safari, and Firefox without per-browser caveats. Trusted Types forces clean code patterns across the whole codebase and provides hard defence-in-depth on every browser that lands on the page.
The recommended rollout is Content-Security-Policy-Report-Only: require-trusted-types-for 'script' first. Collect violation reports, fix every offending assignment, then promote to enforcing mode.
DOMPurify and contextual sanitisation
Where the product genuinely needs to render attacker-shaped HTML (rich-text comments, CMS body fields, markdown with embedded HTML, RSS feed renderers), the canonical sanitiser is DOMPurify. It parses the HTML in a sandboxed document, walks the tree, strips every element and attribute not on its allow-list, and returns a string that is safe to assign to innerHTML.
import DOMPurify from 'dompurify';
element.innerHTML = DOMPurify.sanitize(userHtml, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br', 'ul', 'ol', 'li', 'code', 'pre'],
ALLOWED_ATTR: ['href'],
ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i,
});Pair it with Trusted Types via RETURN_TRUSTED_TYPE: true. That makes DOMPurify return a TrustedHTML object, and the Trusted Types policy becomes a one-liner that calls into DOMPurify. The two layers compose: DOMPurify is the actual content sanitiser, Trusted Types is the structural guarantee that no other path can reach the sink.
The mistake to avoid is rolling a regex-based sanitiser. Every regex-based HTML sanitiser I have ever seen has been broken within a week of public scrutiny. HTML parsing is hairy enough that the only correct approach is parsing with a real HTML parser and walking the tree, which is what DOMPurify does.
Other defences
textContent, not innerHTML. If the value is text that should render as text, element.textContent = userInput sets it as a text node and the browser will not parse HTML in it at all. The bug pattern is reaching for innerHTML because someone needs <br> to render as a line break, and then the same sink ends up handling every value forever.
Safe DOM construction APIs: document.createElement, element.setAttribute, element.appendChild build the DOM as a tree of typed nodes rather than parsing a string. No parsing step means no HTML injection.
Framework safe-by-default rendering. React's {value}, Vue's {{ value }}, Angular's {{ value }}, Svelte's {value} all render as text by default. The escape hatches (dangerouslySetInnerHTML, v-html, bypassSecurityTrustHtml, {@html}) are exactly where DOM XSS lives in modern code. Grep for each and audit every call site.
URL scheme validation. Any code that interpolates a user-controlled value into <a href>, <iframe src>, <form action>, or window.location should validate the scheme against an http:/https:/mailto: allow-list and reject javascript:. A URL constructor wrapper is the cleanest form.
CSP script-src with nonces does not fully cover DOM XSS (the dangerous script is one your own application loaded with a valid nonce), but it still blocks the secondary payloads an attacker would want to load. Treat CSP and Trusted Types as complementary, not redundant.
Real-world incidents
A short tour of DOM XSS in production. Each entry is limited to claims I am confident enough to assert; verify specifics against the linked advisory before quoting elsewhere.
- Google search box DOM XSS (multiple historical reports). Several historical reports against Google's own search interface involved DOM XSS where a query parameter or fragment was read by client-side JS and written to a sink. Specific reproducer paths have been fixed; the pattern (high-traffic single-page interface, fragment routing, custom highlighting code) is the canonical class. No specific CVE is cited because the public bug reports are old; treat as "the class has hit Google more than once".
- jQuery
$.html()and$(...)parsing. A long tail of jQuery-plugin DOM XSS reports trace to the rule that$(stringStartingWithLessThan)parses the string as HTML. Carousel, lightbox, and tab plugins that read a URL fragment and pass it to$(...)for a "scroll to" or "open tab" behaviour have all shipped this bug. The class is so common that jQuery 3.5.0 (April 2020) removed thehtmlPrefilterregex that rewrote self-closing tags, closing a long-standing XSS edge case in$(...)HTML parsing. - Slack DOM XSS via shared message rendering. Slack has paid out for DOM XSS reports against the message renderer over the years; public bug-bounty disclosures show the pattern of "rich-text rendering pipeline plus a sink that did not sanitise". No specific CVE; the HackerOne report list under slack.com is the authoritative reference.
Where to go next
Back up to the cross-site scripting overview for the three-variant comparison and the cookie-theft chain. The sibling deep dives are reflected XSS (server reflection variant, easier to find in scanning, easier to deliver via phishing), stored XSS (database-backed, the variant that scales without per-victim delivery), and XSS stealing session cookies (the full token-theft chain end to end, including the HttpOnly sidesteps via Evilginx and infostealer malware).
For the wider map of web application security classes, the web application security vulnerabilities taxonomy is the hub.
Sources
Authoritative references this article was fact-checked against.
- OWASP, DOM-Based XSSowasp.org
- PortSwigger, DOM-based XSSportswigger.net
- web.dev, Trusted Typesweb.dev





