In short

DOM-based XSS happens when page script copies attacker-controlled data, such as location.hash, a postMessage payload or stored JSON, into an API that parses HTML or runs code: innerHTML, document.write, eval, string timers, javascript: URLs or jQuery html(). Write text with textContent, sanitize unavoidable HTML with DOMPurify, and enforce Trusted Types with require-trusted-types-for 'script'. Accept messages only from an exact origin and the expected window, then validate their shape.

  • Treat location, document.referrer, window.name, web storage and message data as attacker-controlled. The URL fragment never reaches your server, so server-side filters and logs never see it.
  • Replace sinks rather than filter input: textContent instead of innerHTML, functions instead of strings in timers, parsed and checked URLs instead of raw href assignments.
  • Trusted Types has been Baseline since February 2026 (Chrome and Edge 83, Firefox 148, Safari 26). Under require-trusted-types-for 'script', a plain string at a sink throws unless a named policy produced it.
  • Compare event.origin with an exact origin, check event.source, then validate the message type and fields. When the data matters, send with an explicit targetOrigin, never '*'.
  • DOM clobbering turns scriptless markup into script gadgets: keep configuration in const bindings, check types with instanceof, and prefix or strip id and name values in user HTML.
  • AngularJS evaluates {{ }} in server-rendered text inside ng-app even after HTML escaping. Wrap that text in ng-non-bindable and migrate; support ended in January 2022.

Verify it

  1. Search for innerHTML, outerHTML, insertAdjacentHTML, document.write, eval, new Function, string setTimeout and setInterval, .html(), $(string), dangerouslySetInnerHTML, v-html, bypassSecurityTrust and {@html}, and trace each argument back to its source.
  2. Send Content-Security-Policy-Report-Only: require-trusted-types-for 'script' on every page and work through the reports; each one names a sink and the start of the string that reached it.
  3. Load each page with payloads in the fragment, the query string and window.name, and assert in a headless browser that no element was created and no canary variable was set.
  4. From a page on another origin, frame or open every page that listens for messages and post hostile and malformed messages; nothing may change on the page.
  5. Put scriptless markup with id and name values such as currentScript and your configuration globals into every field that accepts HTML, then check that script URLs and settings are unchanged.
  6. Search server templates for ng-app and Vue mount points that contain user content, and add a regression test in which {{7*7}} renders literally.
Applies to
  • Trusted Types Chrome and Edge 83+, Firefox 148+, Safari 26+ (tested Chrome 154)
  • Element.setHTML() Chrome 146+, Firefox 148+; not in Safari on 11 October 2026
  • DOMPurify 3.4.x (tested 3.4.16)
  • jQuery 4.0.0 for Trusted Types; 3.5.0 minimum (tested 3.4.1, 3.5.0, 3.7.1, 4.0.0)
  • React 19.0+ blocks javascript: URLs; 19.3+ keeps Trusted Types in attributes (tested 19.2.7, 19.3.0)
  • AngularJS 1.x, end of life (tested 1.8.3)

Browser code in single-page and multi-page applications. Framework escaping does not cover direct DOM APIs or explicit escape hatches.

Part of
Workflow: Fix a finding

Fix it in your stack

Tested fixes: the vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

Learning goals and prerequisitesAbout 33 minutes: 10 to read and 23 for the exercise

What you will learn

  • Trace untrusted values from browser sources to sinks and rate the risk.
  • Replace HTML, code and URL sinks with safe APIs, and sanitize where HTML is a feature.
  • Roll out Trusted Types from report-only mode to enforcement.
  • Validate postMessage origin, source and data, and remove DOM clobbering and AngularJS template injection risks.
  • Write browser tests that prove a sink no longer runs input.

Before you start

Front-end developers, AppSec engineers and reviewers who own browser code in single-page or server-rendered applications

  • Read JavaScript that manipulates the DOM.
  • Know how reflected and stored XSS work and what the same-origin policy separates.
  • Be able to set response headers in your server or CDN.
Go to the guided practice

Checking a postMessage before acting on it

Attacker siteYour originpartner.exampleAttacker pageapp.example.comPartner widgetMessage from lookalike origin1Origin mismatch: ignore2resize, height 6403Check source and shape3Set height; no HTML sink4Reply with exact targetOrigin4Trust boundaryUntrusted input1Step, described below
  1. Hostile senderA page on https://partner.example.evil.net frames the app and posts a message. A prefix check on event.origin would accept it.
  2. Exact originThe receiver ignores any message whose event.origin is not exactly https://partner.example, including 'null' from sandboxed frames.
  3. Window and shapeA message from that origin must also come from the embedded frame (event.source === frame.contentWindow) and be a resize with an integer height from 100 to 2000.
  4. Narrow actionThe handler only sets the frame's height, never HTML. Messages the app sends name https://partner.example as targetOrigin.
Origin, window and schema are separate checks. A trusted partner with its own XSS bug can still send hostile data, so message data never reaches an HTML or code sink.

DOM XSS happens entirely in the browser

In DOM-based XSS the server can send a perfectly safe page. The flaw is in the page's own script, which reads an attacker-controlled value (the source) and passes it to an API that parses HTML or compiles code (the sink). Browsers never send the URL fragment to the server, so an attack carried in location.hash never appears in server logs, WAF rules or server-side output encoding.

The script below has one source and three sinks. innerHTML parses the decoded fragment, so an img element with an onerror handler runs; script elements inserted through innerHTML do not run, which is why payloads use event handlers. setTimeout compiles a string argument like eval, so a campaign value that closes the quote runs too. The help link accepts a javascript: URL that runs on click. The browser percent-encodes < and > in the fragment; decodeURIComponent turns them back into markup.

Vulnerablewelcome.jsJavaScript
// welcome.js on https://app.example.com/welcome
// Attack: /welcome?campaign=x%22);alert(1);//&help=javascript:alert(1)#<img src=x onerror=alert(1)>
const params = new URLSearchParams(location.search);
const name = decodeURIComponent(location.hash.slice(1));

document.querySelector('#greeting').innerHTML = 'Welcome back, ' + name;  // HTML sink
setTimeout('track("' + params.get('campaign') + '")', 0);                 // code sink
document.querySelector('#help').href = params.get('help') ?? '/help';     // javascript: URL sink

Know the sources, replace the sinks

A source is any value that another site or user can influence: location and document.URL, document.referrer, window.name, postMessage data, cookies, web storage, and JSON from your own API that contains other users' input. That last source makes the bug stored rather than reflected. Sinks are fewer and easier to find, so review starts there. Use the replacement in the table, and treat every sink you keep as code that needs a sanitizer or a Trusted Types policy. The sanitizer lab at /labs/html-sanitizer/ practises the configuration for user-supplied HTML.

Know the sources, replace the sinks
SinkWhat goes wrongUse instead
innerHTML, outerHTML, insertAdjacentHTML, document.write, createContextualFragment, iframe srcdocThe string is parsed as markup, so event handlers runtextContent, or createElement with fixed attribute names; a sanitizer when HTML is a feature
eval, new Function, setTimeout and setInterval with a string, setAttribute('on…')The string is compiled as JavaScriptPass functions, parse data with JSON.parse, attach handlers with addEventListener
location, a.href, form.action, iframe.src, window.openjavascript: URLs run code when followedURL.parse() against your origin, then allow only http: and https: on expected hosts
script.src, import(), Worker and importScripts URLsCode loads from the given URLFixed URLs, or an allowlist of your own paths
jQuery html(), append(), $(string) and similar helpersStrings that look like HTML are parsed.text(), or sanitized HTML with jQuery 3.5.0 or later

Fix: keep data as text, functions and checked URLs

The fixed script keeps every input as data. textContent creates a text node, so the payload is displayed character for character. The timer receives a function that closes over the value. The help link is parsed against the page's origin and assigned only if it stays on that origin, which rejects javascript:, data: and other sites. URL.parse() returns null instead of throwing on malformed input and is available in Chrome and Firefox 126 and Safari 18.

Fixing the sink beats filtering the input; /domains/appsec/secure-coding/xss-output-encoding/ covers the server-rendered side. A blocklist of tags or of the word script misses event handlers, SVG, entity encoding and parser quirks, and it mangles legitimate text.

Fixedwelcome.jsJavaScript
// welcome.js, fixed: the same inputs now stay data
const params = new URLSearchParams(location.search);
const name = decodeURIComponent(location.hash.slice(1));

document.querySelector('#greeting').textContent = 'Welcome back, ' + name;  // text node

const campaign = params.get('campaign') ?? '';
setTimeout(() => track(campaign), 0);                                       // a function

const help = URL.parse(params.get('help') ?? '/help', location.origin);
if (help && help.origin === location.origin) {                              // same-origin only
  document.querySelector('#help').href = help.href;
}

Framework escape hatches

React, Vue, Angular and Svelte escape text interpolation, so DOM XSS in single-page apps comes from the APIs that opt out of escaping and from code that reaches around the framework through refs or ElementRef. React 19.0 replaces javascript: URLs in href with one that throws when followed; it does not check other schemes, so validate URLs yourself. Angular sanitizes values bound to innerHTML. Vue's guide says never to use untrusted content as a template and never to mount Vue on server-rendered HTML that contains user content.

The component sanitizes before the escape hatch and links only to https URLs. Under Trusted Types, DOMPurify returns a TrustedHTML object and React hands __html to innerHTML unchanged (tested 19.2.7 and 19.3.0). React 19.3, released in September 2026, also stopped converting attribute values to strings, so TrustedHTML in an attribute such as srcDoc now works where 19.2.7 threw a TypeError.

Framework escape hatches
FrameworkReview theseNote
ReactdangerouslySetInnerHTML, href and src from data, refs that set innerHTMLjavascript: URLs blocked since 19.0
Vuev-html, :href and :src from data, in-DOM templatesNever mount on pages that contain user content
AngularbypassSecurityTrustHtml and its siblings, ElementRef.nativeElementTrusted Types policies: angular, angular#unsafe-bypass
Svelte{@html}Only for sanitized or developer-controlled strings
FixedComment.jsxJsx
import DOMPurify from 'dompurify';

// Content-Security-Policy: require-trusted-types-for 'script'; trusted-types dompurify
export function Comment({ html, website }) {
  // RETURN_TRUSTED_TYPE yields TrustedHTML; React hands __html to innerHTML unchanged.
  const clean = DOMPurify.sanitize(html, { RETURN_TRUSTED_TYPE: true });
  const site = URL.parse(website ?? '');
  return (
    <article>
      <div dangerouslySetInnerHTML={{ __html: clean }} />
      {site?.protocol === 'https:' && <a href={site.href}>Website</a>}
    </article>
  );
}

Enforce Trusted Types so missed sinks fail closed

Trusted Types turns DOM XSS into a type error. With require-trusted-types-for 'script', the browser rejects plain strings at HTML sinks, script sinks and script URL sinks, and blocks navigation to javascript: URLs. Only objects created by a policy pass, and the trusted-types directive lists the policy names the page may create; a second policy with the same name fails unless 'allow-duplicates' is present. Review then covers a few policy functions instead of every innerHTML in the codebase. Chrome 154 also blocked eval and new Function on strings under enforcement.

Roll out in report-only mode first; the reports are your inventory of sinks. A policy named default receives every string that would otherwise throw, which helps with third-party code during migration, but a default policy that returns its input unchanged removes the protection. Check libraries: jQuery 3.x fails to initialize under enforcement, while jQuery 4.0 accepts TrustedHTML in html(). Element.setHTML(), in Chrome 146 and Firefox 148 but not in Safari on 11 October 2026, sanitizes as it inserts and needs no policy.

Fixedtrusted-html.jsJavaScript
// trusted-html.js: the only place the app turns a string into TrustedHTML.
// Content-Security-Policy: require-trusted-types-for 'script'; trusted-types app-html dompurify
import DOMPurify from 'dompurify';

// DOMPurify parses through its own 'dompurify' policy, so the CSP must allow that name too.
const sanitize = (dirty) => DOMPurify.sanitize(dirty, { SANITIZE_NAMED_PROPS: true });

// Browsers without Trusted Types (Safari before 26) still get the sanitizer.
const policy = globalThis.trustedTypes?.createPolicy('app-html', { createHTML: sanitize })
  ?? { createHTML: sanitize };

export function setUserHTML(element, dirty) {
  element.innerHTML = policy.createHTML(dirty);  // a raw string here throws a TypeError
}

postMessage: an origin check that isn't one

Any page that holds a reference to your window can post a message to it, including any site that frames your page or opens it in a popup. The browser reports the sender's origin in event.origin, and checking it is the receiver's job. Prefix and suffix checks fail: https://partner.example.evil.net starts with https://partner.example, and endsWith('partner.example') also accepts https://notpartner.example. Unescaped dots in a regular expression match any character. In the test, a page on partner.example.evil.net framed the app and ran script through the banner's innerHTML.

Sandboxed iframes and other documents with an opaque origin send the origin 'null', so never put that value on an allowlist. Checking the origin is not enough when the data then reaches a sink: a trusted partner with its own XSS bug becomes a path into your page.

Vulnerablemessages.jsJavaScript
// Receiver on https://app.example.com
window.addEventListener('message', (event) => {
  // A prefix check: https://partner.example.evil.net passes it too.
  if (event.origin.indexOf('https://partner.example') !== 0) return;
  document.querySelector('#banner').innerHTML = event.data.html;  // and an HTML sink
});

Check origin, source and shape before acting

The fixed receiver applies three checks: the exact origin string, the exact window it embedded, and a schema that allows one message type with a bounded integer. The action writes a style value, never HTML. In testing, a resize from the real widget was applied, while resize messages from a second partner frame, a sandboxed frame and an attacker page that framed the app were ignored. ASVS 5.0.0 requirement 3.5.5 asks for both the origin and the syntax checks.

The sender needs the same care. Name the receiving origin as targetOrigin, and the browser drops the message if the target window now holds a document from another origin. With '*', the tested app delivered a token to evil.example after its partner frame navigated there. For sign-in popups, also check that the reply answers a request this window made, for example with a state value.

Fixedmessages.jsJavaScript
// Receiver on https://app.example.com, which embeds the widget in <iframe id="partner-widget">
const PARTNER_ORIGIN = 'https://partner.example';
const frame = document.querySelector('#partner-widget');

window.addEventListener('message', (event) => {
  if (event.origin !== PARTNER_ORIGIN) return;        // exact match; never accept "null"
  if (event.source !== frame.contentWindow) return;   // only the frame we embedded
  const msg = event.data;
  if (msg === null || typeof msg !== 'object' || msg.type !== 'resize') return;
  if (!Number.isInteger(msg.height) || msg.height < 100 || msg.height > 2000) return;
  frame.style.height = `${msg.height}px`;             // one narrow action, no HTML
});

// Sending: name the receiving origin. With '*', whatever page the frame
// has navigated to receives the message.
function sendTheme(theme) {
  frame.contentWindow.postMessage({ type: 'theme', value: theme }, PARTNER_ORIGIN);
}

DOM clobbering: markup that answers your lookups

Browsers expose elements with an id, and some elements with a name, as properties of window and document, and the document's named properties even take precedence over its built-in ones. An attacker who can inject only scriptless HTML, which most sanitizers allow, can therefore answer lookups that your code expects to return configuration or browser objects. CVE-2024-43788 in webpack and CVE-2024-47068 in Rollup worked this way: an injected img named currentScript replaced document.currentScript, and its src became the base URL for loading more script.

In the test, both lookups returned the attacker's values, and the type check fell back safely. Keep configuration in const bindings, never read settings from window or document properties, and check DOM objects with instanceof before trusting them. Object.freeze on a configuration object is a reasonable extra, but it is shallow and does not protect lookups on window or document. DOMPurify's defaults kept appConfig and cdn; SANITIZE_NAMED_PROPS: true prefixed them with user-content-.

config.jsJavaScript
// Markup a sanitizer kept, because it has no script or event handlers:
//   <img name="currentScript" src="https://evil.example/">
//   <a id="appConfig"></a><a id="appConfig" name="cdn" href="https://evil.example/">

// Vulnerable: named elements answer these lookups.
const base = document.currentScript.src;        // "https://evil.example/"
const cdn = window.appConfig?.cdn || '/static';  // an <a> that stringifies to its href

// Fixed: check the type you got, and keep configuration in a const binding.
const current = document.currentScript;
const safeBase = current instanceof HTMLScriptElement ? current.src : location.href;
const config = Object.freeze({ cdn: '/static' });

Client-side template injection in AngularJS

AngularJS 1.x compiles everything inside an ng-app element, including text the server rendered. HTML escaping does not help, because {{ }} contains no HTML-special characters: the escaped review below still ran in AngularJS 1.8.3. Version 1.6 removed the expression sandbox because developers kept treating it as a security boundary, which it never was; any expression that reaches the compiler can run arbitrary JavaScript. User input passed to $eval, $parse, $compile or an orderBy expression has the same effect.

AngularJS support ended in January 2022, so the lasting fix is migration. Until then, keep user content out of compiled regions or wrap it in ng-non-bindable, which AngularJS skips. Current frameworks state the same rule: Angular warns against building templates by concatenating user input, and Vue against using untrusted content as a template.

review.htmlHTML
<!-- Server-rendered page using AngularJS 1.x. The template engine HTML-escaped the review,
     but {{ }} is not HTML, so AngularJS still evaluates it as an expression. -->
<div ng-app>
  <p class="review">{{constructor.constructor(&#39;alert(document.domain)&#39;)()}}</p>

  <!-- Until you migrate: AngularJS does not compile inside ng-non-bindable. -->
  <p class="review" ng-non-bindable>{{constructor.constructor(&#39;alert(document.domain)&#39;)()}}</p>
</div>

Find sinks in code and prove fixes in a browser

Start from sinks: search for the APIs in the table and each framework's escape hatch, and let Mozilla's eslint-plugin-no-unsanitized or CodeQL's js/xss query flag new ones in CI. Trace each argument back to a source. Then test in a real browser, because only its parser decides what runs. The test below fails on the vulnerable welcome.js and passes on the fixed one. It asserts that no element was created as well as that no script ran, which is stronger than waiting for an alert.

Testwelcome.test.mjsJavaScript
// welcome.test.mjs: run with BASE_URL and CHROME_PATH set, e.g. node --test
import { test } from 'node:test';
import assert from 'node:assert/strict';
import puppeteer from 'puppeteer-core';

const PAYLOAD = '<img src=x onerror="window.__xss=1">';

test('the greeting renders the URL fragment as text', async (t) => {
  const browser = await puppeteer.launch({ executablePath: process.env.CHROME_PATH });
  t.after(() => browser.close());
  const page = await browser.newPage();
  await page.goto(`${process.env.BASE_URL}/welcome#${encodeURIComponent(PAYLOAD)}`,
    { waitUntil: 'networkidle0' });

  assert.equal(await page.$('#greeting img'), null);  // no element was created
  assert.equal(await page.$eval('#greeting', (el) => el.textContent), `Welcome back, ${PAYLOAD}`);
  assert.equal(await page.evaluate(() => window.__xss), undefined);  // and nothing ran
});

Pitfalls

Most surviving DOM XSS sits next to a control that checks the wrong thing.

  • Sanitizing and then changing the result: concatenation or a regex replace after DOMPurify can recreate active markup.
  • Sanitizing for HTML and then using the value as a URL, a script or a template; each sink needs its own control.
  • Leaving 'dompurify' out of the trusted-types list: DOMPurify then cannot parse, returns an empty string and looks like a rendering bug.
  • Checking event.origin and then forwarding event.data to innerHTML, eval or location.
  • Running jQuery older than 3.5.0. Its html() rewrote markup before parsing (CVE-2020-11022, CVE-2020-11023), and CISA added CVE-2020-11023 to its Known Exploited Vulnerabilities catalog in January 2025.
  • Counting on a strict CSP alone. It stops injected inline handlers, but not a gadget in your own trusted script, such as code that sets script.src from data under 'strict-dynamic'.

Guided practice

Review the browser code of a support portal before release. Decide which lines are exploitable, then specify fixes and tests.

Supplied scenario material
Plain text
Origin: https://support.example.test. Customers write ticket subjects; the help widget is served from https://help.example.test and framed at #help-widget.
Libraries: jQuery 3.4.1 on every page; AngularJS 1.8.3 on /kb pages. S1 to S4 run on every page.
S1  document.title = 'Ticket ' + decodeURIComponent(location.hash.slice(1));
S2  const t = await (await fetch('/api/tickets/42')).json(); $('#subject').html(t.subject);
S3  window.addEventListener('message', (e) => {
      if (/^https:\/\/help.example.test$/.test(e.origin)) location.href = e.data.next;
    });
S4  const cfg = window.supportConfig || { uploadUrl: '/upload' };
    fetch(cfg.uploadUrl, { method: 'POST', body: formData });
S5  /kb/article (server-rendered, HTML-escaped): <div ng-app> ... <p>{{ customer comment }}</p> ... </div>
Customer comments on /kb may contain HTML that the server sanitizes with DOMPurify defaults before rendering.
  1. 1. Classify each line

    For S1 to S5, name the source and the sink, and decide whether an attacker can run script or steal data.

    Expected result and reasoning

    S1 is safe: document.title is a text sink. S2 is stored DOM XSS: a customer subject reaches jQuery html(). S3 accepts origins such as https://help-example.test because the dots are unescaped, and location.href accepts javascript: URLs, so it is XSS as well as an open redirect. S4 is DOM clobbering: a comment containing elements with id supportConfig and a nested name uploadUrl redirects the upload. S5 is client-side template injection; HTML escaping does not neutralize {{ }}.

  2. 2. Fix the message handler

    Rewrite S3 so that only the help widget can trigger navigation, and only within the portal.

    Expected result and reasoning

    Compare e.origin === 'https://help.example.test', require e.source to be the widget frame's contentWindow, require a known message type, then pass the path through URL.parse(path, location.origin) and navigate only if the result's origin equals location.origin. A fixed map from message values to routes is better still.

  3. 3. Fix S2 and S4

    Choose the safe API for the subject, and remove the clobbering gadget.

    Expected result and reasoning

    Use $('#subject').text(t.subject), or textContent. Define the upload URL as a const in the module, never read it from window, and sanitize comments with SANITIZE_NAMED_PROPS: true so user id and name values gain the user-content- prefix.

  4. 4. Plan Trusted Types

    List the steps to reach an enforced require-trusted-types-for 'script' policy, and what breaks first.

    Expected result and reasoning

    Deploy it in report-only mode and fix each reported sink. jQuery 3.4.1 fails to initialize under enforcement, so upgrade to jQuery 4.0, which also removes the CVE-2020-11022 and CVE-2020-11023 exposure. Create one policy that wraps DOMPurify and allow both names: trusted-types app-html dompurify. Treat the AngularJS /kb pages separately: ng-non-bindable around comments now, then migrate.

  5. 5. Write the regression tests

    Specify four browser tests that would have caught these bugs.

    Expected result and reasoning

    A fragment payload on the ticket page creates no element. A subject of <img src=x onerror=...> renders as text. Messages from https://help-example.test, from a sandboxed frame and from the right origin with a javascript: URL all leave location unchanged. A /kb comment containing {{7*7}} renders literally, and one containing clobbering markup leaves the upload URL unchanged.

Your deliverable: A table of S1 to S5 with source, sink, verdict and fix, plus the four test cases.

Paper exercise: the fixture is complete. You can reproduce each finding with a local server and a current Chromium-based browser.

Save a draft on this device to resume later. Automatic saving begins after you choose Save. Drafts are never sent by this application; use synthetic data.

No draft saved on this device yet.

Sources & further reading

The standards, specifications and documentation behind the guidance above. Each is the evidence for a claim on this page; consult it for detail beyond the defaults and versions listed here.

  1. W3C Trusted Types (Working Draft, 7 October 2026)
  2. MDN: Trusted Types API, injection sinks and the default policy
  3. HTML Standard: cross-document messaging, security for authors
  4. OWASP DOM based XSS Prevention Cheat Sheet
  5. OWASP DOM Clobbering Prevention Cheat Sheet
  6. GitHub advisory GHSA-4vvj-4cpr-p986: webpack DOM clobbering gadget (CVE-2024-43788)
  7. AngularJS Developer Guide: Security (templates, expressions and sandbox removal)
  8. Angular: Security best practices (sanitization and Trusted Types)
  9. OWASP ASVS 5.0.0, V3 Web Frontend Security (3.2.2, 3.2.3, 3.5.5). Version 5.0.0.
  10. jQuery 4.0.0 release notes (Trusted Types support)

Reference information

SDLC phase
Development

Download this guide as Markdown