Most XSS defenses - output encoding on the server, a strict Content-Security-Policy - protect against payloads that travel through HTML rendered by the server. DOM-based XSS happens entirely in the browser, after the page has already loaded: JavaScript takes data from an untrusted source and writes it into a dangerous sink without ever touching the server again. A perfectly-encoded server response can still be exploited if the client-side code that runs afterward isn't careful.
The Source-to-Sink Model
Every DOM-based XSS vulnerability has the same shape: untrusted data flows from a source (something an attacker can influence) to a sink (a browser API that can execute code or change page behavior), without sanitization in between.
Common sources:
location.href,location.search,location.hash- the URL, fully attacker-controlled via a crafted linkdocument.referrer- the referring page, controllable by whoever links to your sitewindow.name- persists across navigations and is often overlookedpostMessagedata from another window, if the origin isn't verified- Any API response rendered client-side without re-checking it at the point of use
Common sinks:
element.innerHTML/outerHTML- parses the string as HTML, executing any<script>or event handler attribute in itdocument.write()/document.writeln()eval(),Function(),setTimeout(string),setInterval(string)element.src/element.hrefset to ajavascript:URL- jQuery's
.html(),.append()with unsanitized strings
innerHTML: The Most Common Sink in Practice
// Vulnerable: URL hash rendered directly as HTML
const name = location.hash.slice(1); // #
document.getElementById('greeting').innerHTML = `Hello, ${name}!`;
// The onerror handler executes the moment the (broken) image tag is parsed
Nothing about this requires the server to be involved at all - the payload lives entirely in the URL fragment (everything after #), which browsers never send to the server in the first place. A WAF or server-side input validation sees nothing, because there is nothing to see on that side.
// Fixed: textContent never interprets its argument as HTML
document.getElementById('greeting').textContent = `Hello, ${name}!`;
// If you genuinely need to render HTML (rich text, markdown output),
// sanitize it first with a purpose-built library
import DOMPurify from 'dompurify';
document.getElementById('content').innerHTML = DOMPurify.sanitize(untrustedHtml);
Framework Escape Hatches
Modern frameworks auto-escape by default, which is why DOM-based XSS in React or Vue code usually traces back to one specific, deliberately-named function that opts back out of that protection.
// React: dangerouslySetInnerHTML is named the way it is on purpose
function Comment({ userSuppliedHtml }) {
return ;
// Any