Safeguard
AppSec

DOM-Based XSS Attacks: How They Work and How to Prevent Them

A DOM based XSS attack executes entirely in the browser, which is why your server-side filters and access logs never see it. Here is how the source-to-sink flow works and what actually stops it.

Priya Mehta
Security Analyst
7 min read

A DOM based XSS attack is a cross-site scripting attack where the vulnerability lives entirely in client-side JavaScript: the page's own code reads attacker-controllable data from the DOM (a URL fragment, a query parameter, postMessage data) and writes it into a dangerous sink like innerHTML, executing script without the payload ever touching the server. That last property is what makes it nasty. Your WAF does not see it, your server-side output encoding never runs, and if the payload rides in a #fragment, it does not even appear in your access logs.

What Is DOM Based XSS, Exactly?

The classic taxonomy splits XSS three ways. Reflected XSS bounces a payload off the server in the response to a crafted request. Stored XSS persists the payload server-side and serves it to victims later. In both, the server assembles the malicious HTML.

So what is DOM based XSS in contrast? The server's response is completely innocent. The vulnerability is a data flow inside the browser: JavaScript that ships with the page takes a source the attacker can influence and passes it, unsanitized, to a sink that interprets strings as code or markup. The attack happens during or after page load, in the DOM, hence the name. In CWE terms it is still CWE-79, but the fix location is your front-end code, not your templates.

Common sources:

  • location.hash, location.search, location.href, document.URL
  • document.referrer
  • window.name
  • postMessage event data
  • Anything read back from localStorage or sessionStorage that an attacker could have seeded

Common sinks:

  • element.innerHTML, outerHTML, insertAdjacentHTML
  • document.write / document.writeln
  • eval, Function, setTimeout/setInterval with string arguments
  • element.src / href assignments that accept javascript: URLs
  • jQuery's $(...), .html(), .append() with untrusted strings

The vulnerability is never a source or a sink alone. It is an unbroken, unsanitized path between the two.

A Concrete DOM Based XSS Example

Here is the kind of code that ships constantly. A page wants to greet the user by a name passed in the URL fragment:

// https://example.app/welcome#Alice
const name = decodeURIComponent(location.hash.slice(1));
document.getElementById("greeting").innerHTML = "Welcome, " + name;

With #Alice this renders a greeting. With a fragment like #<img src=x onerror=alert(document.domain)>, the browser parses the injected markup, the image fails to load, and the onerror handler runs in the page's origin. From there the attacker's script can read the DOM, ride the victim's session, or quietly exfiltrate whatever the page can access.

Note what the server saw: a GET request for /welcome. Fragments are not sent to the server at all. This dom based xss example is exactly why "we validate everything server-side" is not a complete answer to XSS.

The same shape appears with postMessage:

window.addEventListener("message", (event) => {
  // Missing: event.origin check
  document.getElementById("panel").innerHTML = event.data;
});

Any page that can get a reference to your window (an opener, an embedding page) can now inject markup into it.

Why These Bugs Are Easy to Ship and Hard to Spot

Three structural reasons. First, the source and the sink are often far apart: the URL gets parsed in a router module, stored in state, and rendered by a component three files away, so no single diff looks dangerous. Second, front-end code churns fast and is heavily reviewed for behavior, rarely for taint flow. Third, single-page-app frameworks protect you by default and provide sharp escape hatches: React's dangerouslySetInnerHTML, Angular's bypassSecurityTrustHtml, Vue's v-html. Grep your codebase for those three strings today; every hit is either justified with a sanitizer or a finding.

Server-side scanners share the blind spot. A crawler that only inspects HTTP responses will not observe a client-side source-to-sink flow. You need either static analysis of the JavaScript itself or a DAST tool that executes the page in a real browser engine and traces tainted values into sinks, the way Burp's DOM Invader does interactively.

How to Prevent DOM Based XSS

Treat untrusted data as text, never as markup

The single highest-value habit: use APIs that cannot interpret input as HTML.

// Instead of: el.innerHTML = name
el.textContent = name;

textContent (and attribute assignment via setAttribute for non-URL attributes) renders the payload inert. Most "I need innerHTML" cases are really string interpolation that textContent plus a couple of created elements handles fine.

Sanitize when you genuinely need rich HTML

If users legitimately supply formatted content, sanitize it with a maintained library rather than a hand-rolled regex:

import DOMPurify from "dompurify";
el.innerHTML = DOMPurify.sanitize(userHtml);

Keep the sanitizer at the sink, not at the source, so refactors cannot route around it.

Deploy Trusted Types

Trusted Types is the structural fix in Chromium-based browsers. With this CSP header:

Content-Security-Policy: require-trusted-types-for 'script'

every assignment to a dangerous sink (innerHTML, document.write, eval, script src) throws unless the value passed is a Trusted Type produced by a policy you defined. Your entire application converges on a small, reviewable set of policy functions where sanitization is enforced. Start in report-only mode, fix the violations, then enforce.

Validate URLs before assigning them

For sinks like a.href or location, block javascript: schemes with an allowlist: parse with new URL(value, location.origin) and accept only http: and https: protocols.

Check origins on postMessage

Every message listener should verify event.origin against an exact allowlist before touching event.data, and should treat the data as data, never as markup or code.

Keep CSP as a backstop, not a primary defense

A strict CSP (nonced scripts, 'strict-dynamic', no unsafe-inline) meaningfully raises the cost of exploiting a DOM XSS bug, but injection into an already-trusted script context can still slip through. CSP limits blast radius; it does not remove the bug.

Where Supply Chain Risk Meets DOM XSS

A growing share of DOM XSS exposure arrives in code you did not write. Front-end dependencies (widget SDKs, analytics snippets, old jQuery plugins, markdown renderers) carry their own source-to-sink flows, and a vulnerable sanitizer version is a DOM XSS bug in every app that bundles it. Keeping client-side dependencies inventoried and patched is unglamorous but load-bearing; an SCA scanner that flags known-vulnerable front-end packages closes the half of the problem that code review of your code never sees. Pair that with periodic authenticated DAST runs and you cover both the code you wrote and the code you shipped.

FAQ

What is DOM based XSS in one sentence?

It is cross-site scripting where the page's own JavaScript reads attacker-influenced data (like location.hash or postMessage payloads) and writes it into a code-executing sink (like innerHTML or eval), so the exploit happens entirely in the browser.

How is a DOM based XSS attack different from reflected XSS?

In reflected XSS the server embeds the payload into the HTML response; in DOM based XSS the server response is clean and client-side script introduces the payload after load. A reflected cross site scripting example shows up in server logs and can be caught by server-side encoding; a DOM-based one often cannot.

Can a WAF stop DOM based XSS?

Mostly no. Payloads delivered in URL fragments never reach the server, and payloads via postMessage or window.name bypass HTTP inspection entirely. Defenses have to live in the client: safe sinks, sanitization, Trusted Types, and origin checks.

What is the fastest way to find DOM XSS in an existing app?

Grep for dangerous sinks (innerHTML, document.write, eval, dangerouslySetInnerHTML, v-html) and audit each data flow feeding them, then run a browser-based DAST scan that traces sources to sinks at runtime. Enabling Trusted Types in report-only mode is also an excellent discovery tool: violations are a ready-made worklist.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.