DOM-based XSS is a cross-site scripting vulnerability where malicious JavaScript executes because client-side code reads attacker-controlled input and writes it into a dangerous DOM sink. The server never renders the payload. Often the server never receives it at all, because the attack rides in the URL fragment that browsers keep local. That one detail explains why this bug survives in codebases that already passed a server-side review. This guide covers how DOM XSS works, how it differs from reflected and stored XSS, how to find it in your JavaScript, and which controls stop it. The first surprise is where the bug is not.
Key Takeaways
- DOM-based XSS happens in the browser, when JavaScript passes untrusted input from a source into a sink that parses HTML or executes code.
- The URL fragment after the hash character is never sent to the server, so a payload can complete without appearing in a single access log.
- Cross-site scripting sits inside the A05 Injection category of the OWASP Top 10, where CWE-79 accounts for more than 30,000 recorded CVEs.
- The root fix is choosing a safe sink. Use textContent instead of innerHTML, and createElement instead of document.
- Content Security Policy with Trusted Types forces DOM XSS sinks to reject plain strings, and now works across every major browser.
- A web application firewall covers the request-borne half of the problem, not what your JavaScript does after the page loads.
DOM XSS Is a Client-Side Flaw, Not a Server One
The vulnerable code sits in JavaScript that already shipped with your page. Untrusted data arrives through a browser API instead of a server response, so the flaw belongs to the client, and no backend change removes it.
The OWASP Web Security Testing Guide describes it as a bug where browser-side content takes user input through a source and uses it in a sink, leading to execution of injected code. Everything happens after the HTML arrives, so your templating engine can be flawless, and the bug still fires. That is the detail code review keeps missing.
Think of a courier who inspects every package at the gate, then hands the resident a blank label maker. The screening was real. It just finished before the dangerous part.
Sources are the browser APIs an attacker can influence:
- location. hash, location. search and the full location.href
- document.referrer
- window.name
- Message payloads delivered through postMessage
- Values read back out of localStorage or sessionStorage
None of these is validated by the server, and several never reach it. That separates DOM XSS from a server-side injection attack, even though both end with an interpreter treating data as code. So how does a URL fragment turn into running script?
How a DOM XSS Attack Actually Runs
A DOM XSS attack runs in four steps, and the chain completes inside the browser tab.
- The attacker crafts a URL carrying a payload in a source, usually the fragment after the hash.
- The victim opens the link. The browser requests the page normally, and the fragment stays on the client.
- Page JavaScript reads the source and passes the raw string into a sink such as innerHTML, document.write or eval.
- The browser parses that string as markup, and the injected script runs with the origin and privileges of the page.

Search your codebase for sinks before you search for sources. A source with no dangerous sink downstream is harmless, but a dangerous sink almost always has a source someone forgot about.
The payload can fire on page load, on a hash change, or on any interaction that re-renders a component. That timing difference is what the next comparison makes concrete.
Reflected vs Stored vs DOM XSS
All three variants execute script in a victim browser. They differ in where the payload enters, which changes both the fix and your ability to detect it.
In reflected cross-site scripting, the server echoes the payload back inside its response. In stored XSS, it is written to a database and served to everyone who loads that record. In DOM XSS, the server response is identical for every user, and the difference is created client-side.
| Aspect | Reflected XSS | Stored XSS | DOM based XSS |
|---|---|---|---|
| Injection point | HTTP response | Database record | Browser DOM |
| Payload reaches server | Yes, in the request | Yes, once at write time | Often never |
| Fix belongs in | Server template | Server template | Client JavaScript |
| Visible in access logs | Yes | Yes, at write time | No |
| Typical trigger | Crafted link | Any page view | Link, hash change or re-render |

That last row costs the most time during incident response. If your only evidence is server logs, a DOM XSS campaign looks like ordinary traffic. So what does the vulnerable code look like?
DOM XSS Examples You Will Find in Real Code
Most DOM XSS examples are three lines long and were written to solve a small convenience problem. Two patterns show up constantly in production code.
The second pattern is library code you never wrote. CVE-2020-11023 is a DOM-based XSS flaw in the jQuery htmlPrefilter function, affecting versions from 1.0.3 up to 3.5.0. Passing HTML containing option elements to methods such as html() or append() could execute untrusted code even after sanitisation. It appears on the CISA Known Exploited Vulnerabilities catalogue, so it has been used against real targets.
You might be thinking your framework already handles this. React, Angular and Vue escape by default, which helps a great deal, but the escape hatches remain: dangerouslySetInnerHTML, bypassSecurityTrustHtml and v-html each hand raw markup to the parser on request. Most DOM XSS in a single-page application lives in one of those calls.
What an Attacker Gains When the Payload Fires
Once injected, the script runs; it holds the same origin as your own code. Same-origin policy stops protecting you, because the attacker is now inside the origin.
The script reads any cookie without the HttpOnly flag, lifts tokens out of localStorage, and replays them elsewhere. That is session hijacking with your own application as the delivery vehicle. It can also rewrite the page to phish credentials, or fire authenticated requests silently.
This is also where a trusted control quietly fails. A CSRF token assumes an attacker cannot read the current page. Injected script reads it directly, then forges state-changing requests from inside your own document. Fix the XSS first, because everything layered above it inherits the flaw.
How to Detect DOM XSS Vulnerabilities in JavaScript
DOM XSS detection is a taint-tracking exercise. You are tracing a path from an attacker-controlled source to a dangerous sink, using tooling that reads JavaScript rather than HTTP responses.
- Inventory your sinks. Grep the bundle for innerHTML, outerHTML, insertAdjacentHTML, document.write, eval, Function, script.src and href assignments.
- Trace backwards from each hit to see whether the value can originate in location, referrer, window.name, postMessage or storage.
- Run a dynamic pass. DOM Invader in Burp Suite and the Chrome DevTools source-to-sink view highlight live taint flows that static review misses.
- Deploy Content Security Policy in report-only mode first, so violation reports point at real sinks under production traffic before anything is blocked.
- Audit third-party scripts and npm dependencies on the same schedule. Analytics shims and older widget libraries are frequent offenders.
Minified production bundles hide sinks that are obvious in source. Always run detection against a build that carries source maps, then confirm the finding against the deployed bundle.
How to Prevent DOM XSS with Safe Sinks and Trusted Types
DOM XSS prevention starts at the sink, not the input. Encoding a value on the way in does nothing if the destination parses HTML, because the browser decodes and interprets whatever it is handed.
“The best way to fix DOM based cross-site scripting is to use the right output method.”
Work down this order. Each control catches what the one below it misses.
- Swap the sink. textContent, createElement with appendChild, and setAttribute on an allow-listed attribute never parse markup.
- Sanitize only when users genuinely need to author HTML. OWASP recommends DOMPurify, run immediately before insertion with no string edits afterwards.
- Enforce Trusted Types. The require-trusted-types-for directive makes DOM XSS sinks reject plain strings, so every assignment must pass through a reviewed policy.
- Ship a strict Content Security Policy alongside it, using nonce-based script-src rather than a host allow-list.
Trusted Types is worth prioritizing. It moves the guarantee from developer discipline to browser enforcement and shrinks the reviewable surface to a few policy functions. According to MDN documentation on require-trusted-types-for, it now works across the latest versions of all major browsers, so the old excuse about patchy support no longer applies.
Both directives are response headers, so they can be tuned at the edge. Teams running a CDN usually manage them through HTTP header configuration rather than shipping a release for every policy change.

How WAF Helps Detect and Block DOM-Based XSS Attacks
A web application firewall inspects traffic in transit, so it blocks DOM XSS only where the payload crosses the wire. That covers query strings, path segments, headers, and request bodies feeding client-side code, a meaningful share of the real attack surface.
The OWASP Core Rule Set ships generic XSS detection that catches script tags, event handler attributes and javascript: URLs in request parameters, stopping scanning traffic before it reaches your application.
Yes, but the fragment is the gap. Everything after the hash character stays in the browser, and no proxy on the path sees it. OWASP states plainly that intercepting filters are not effective against DOM-based XSS, because that would mean scanning all JavaScript in a response and predicting how it treats tainted input. Anyone selling a WAF as a complete DOM XSS answer is overselling it.
The honest framing is layered. A network firewall solution controls who reaches the application, a WAF service filters what they send, and safe sinks plus Trusted Types govern what the browser does next. The edge also delivers the headers that make that browser layer enforceable.
Final Thought on DOM-Based XSS
DOM-based XSS persists because it sits in the layer most security programs measure least. Server-side review, request logging, and edge filtering all inspect traffic. This bug lives in what your JavaScript does after the traffic stops.
The fix is not exotic. Choose sinks that cannot parse markup, sanitize only when the product truly needs user-authored HTML, and let Trusted Types enforce the rule so it survives the next developer and the next dependency upgrade. Keep request-layer filtering for everything that does cross the wire. Treat every assignment into the DOM as a decision that deserves a reason, and this class of vulnerability stops recurring.
Common Questions About DOM-Based XSS
Can a WAF alone stop DOM-based XSS?
No. A WAF inspects data in transit, and the fragment carrying most DOM XSS payloads never leaves the browser. It does block attacks travelling in query strings, headers or request bodies, so treat it as a partial control rather than a fix.
Does DOM XSS work if my site is fully static?
Yes. Static hosting removes server-side rendering but keeps the browser and its JavaScript. Any static page that reads location.search or a hash and writes it into an HTML sink is vulnerable.
Is encoding user input enough to prevent DOM XSS?
Not on its own. Encoding assumes you know the destination context when you encode. If the value later reaches innerHTML, the browser decodes it and parses the result as markup. A sink that never parses markup removes the guesswork.
Which tools find DOM XSS reliably?
Dynamic taint-tracking tools work best. DOM Invader in Burp Suite and the DOM XSS view in Chrome DevTools trace live source-to-sink flows. Pair them with Content Security Policy in report-only mode to catch sinks that fire only on specific user paths.
Do modern JavaScript frameworks make DOM XSS impossible?
They make the default path safe, but each exposes a deliberate escape hatch. dangerouslySetInnerHTML, bypassSecurityTrustHtml, and v-html all hand raw markup to the parser. Audit those call sites first, because the remaining risk concentrates there.