DOM XSS payloads are strings of attacker-controlled input that get written into a dangerous browser API — a "sink" — where the browser then interprets them as executable code rather than data. Unlike reflected or stored XSS, DOM-based XSS never has to touch your server: the vulnerability lives entirely in client-side JavaScript that reads from an untrusted source and passes it to a sink without sanitizing it. This guide explains the mechanics, shows the shape of the problem defensively, and focuses on detection and remediation.
The reason DOM XSS deserves its own treatment is that server-side defenses miss it completely. Your web application firewall never sees the payload if it arrives through location.hash, and your server-side output encoding does nothing for a string that JavaScript writes into the page after the response has already been rendered.
Sources and sinks: the two halves of every DOM XSS
Every DOM-based XSS example has the same anatomy. A source is where untrusted data enters the JavaScript — location.href, location.hash, location.search, document.referrer, window.name, or postMessage data. A sink is a browser API that can turn a string into markup or code.
The dangerous sinks are worth memorizing because they are the endpoints you audit for:
element.innerHTML/outerHTMLdocument.write()/document.writeln()eval(),Function(),setTimeout/setIntervalwith a string argumentelement.insertAdjacentHTML()- jQuery's
.html(),.append(), and$()when passed markup - assigning to
location/location.href(redirect-based)
A vulnerability exists when a source flows to a sink with no sanitization in between. That flow is the thing you are hunting.
An "xss dom based - introduction" walkthrough, defensively
Consider a page that reads a fragment and displays a greeting. A common introduction to DOM-based XSS uses code like this:
// VULNERABLE: hash flows straight into innerHTML
const name = decodeURIComponent(location.hash.slice(1));
document.getElementById("greeting").innerHTML = "Hello, " + name;
Here location.hash is the source and innerHTML is the sink. If the fragment contains markup instead of a plain name, the browser parses it as HTML. A typical dom based xss payload used to demonstrate this in a lab is an image tag with a broken src and an error handler, which fires JavaScript when the image fails to load. The point of the example is not the payload string — it is that the string was treated as HTML at all.
The fix is to stop treating the value as markup:
// SAFE: textContent never parses markup
const name = decodeURIComponent(location.hash.slice(1));
document.getElementById("greeting").textContent = "Hello, " + name;
textContent writes the value as literal text, so the same input renders as visible characters instead of executing. That single change closes the hole in this example.
The "xss dom based - eval" case
The eval family is its own category because the sink executes code directly, not just markup. A dom based xss example built on eval might parse a URL parameter and pass it to eval() to "conveniently" turn a string into an object:
// VULNERABLE: never do this
const config = eval("(" + location.search.slice(1) + ")");
Any input that reaches eval runs with the full privileges of the page. There is no safe way to sanitize arbitrary input for eval — the remediation is to remove eval entirely. If you are parsing JSON, use JSON.parse. If you are dispatching to a function by name, use an explicit allowlist object rather than resolving the name dynamically.
// SAFE: JSON.parse cannot execute code
const config = JSON.parse(decodeURIComponent(location.search.slice(1)));
The same logic applies to setTimeout("someCode()", 1000) — pass a function reference, never a string.
Why the browser is the only place you can catch it
Because a dom-based xss example plays out after the HTTP response, tools that inspect server responses cannot see the taint flow. Detection has to model the JavaScript itself. That is what a browser-driven scanner does: it loads the page, instruments the DOM sinks, feeds crafted values through the sources, and watches whether any reach a sink unsanitized. A dynamic scanner such as the one described on our DAST product page does exactly this kind of runtime tracing.
Static analysis helps too — you can grep your bundle for sink assignments and trace backward — but modern front-end frameworks generate so much indirection that pure static taint tracking produces heavy false positives. Runtime instrumentation is usually the more reliable signal for DOM XSS.
Remediation that actually holds
Fixing individual sinks is necessary but not sufficient. Layer these defenses:
- Prefer safe sinks. Use
textContent,setAttribute, and DOM methods that treat input as data. ReserveinnerHTMLfor content you fully control. - Sanitize when you must render HTML. If you genuinely need to insert user markup, run it through a vetted sanitizer like DOMPurify before it touches the DOM. Do not hand-roll a regex blocklist; it will lose.
- Adopt Trusted Types. Modern browsers support the Trusted Types API, which makes dangerous sinks reject plain strings at runtime unless they pass through a policy you define. It converts a whole class of DOM XSS into a hard error instead of a silent execution.
- Set a Content Security Policy. A strict CSP with no
unsafe-inlineand nounsafe-evalblunts the impact even when a payload slips through, because injected inline scripts will not run.
Content-Security-Policy: script-src 'self'; require-trusted-types-for 'script'
None of these is a silver bullet alone. Together they make DOM-based XSS both hard to introduce and low-impact when it happens.
Building it into your workflow
Treat DOM XSS as a first-class finding in your pipeline. Run a browser-based dynamic scan against staging on every deploy, keep an inventory of every dangerous-sink usage in your codebase, and gate new innerHTML/eval introductions in code review. Our security academy has a deeper client-side security track if you want to train a team on the full taxonomy of sources and sinks.
FAQ
How is DOM XSS different from reflected XSS?
Reflected XSS bounces the payload off the server — the malicious input appears in the HTTP response. DOM XSS never involves the server response; the vulnerability is in client-side JavaScript that reads an untrusted source and writes to a dangerous sink. That is why server-side filters miss it.
Can a Content Security Policy alone stop DOM XSS?
No, but it significantly reduces impact. A strict CSP blocks inline script execution and eval, which neutralizes many payloads even if the taint flow exists. It is a mitigation layer, not a fix — you still need to remove the source-to-sink flow.
Is innerHTML always dangerous?
Only when it receives untrusted input. Assigning a static, developer-controlled string to innerHTML is fine. The risk is exclusively when a value that originated from a URL, postMessage, or other attacker-influenced source reaches it without sanitization.
What tool finds DOM XSS best?
Browser-driven dynamic scanners that instrument DOM sinks at runtime tend to find DOM-based XSS more reliably than static analysis, because front-end frameworks obscure the taint path from static tools. Combine runtime scanning with a code-review gate on dangerous sink usage.