# DOM XSS (DOM-based cross-site scripting): sources, sinks and fixes

Source: https://penetrationtestservices.com/vulnerabilities/dom-based-xss/
Publisher: Excession Labs (penetrationtestservices.com)
Last updated: 2026-11-09

DOM XSS (DOM-based cross-site scripting) explained: sources and sinks, a worked example, safe replacements, a Trusted Types policy, CSP limits and how testers prove it.

> **Short answer:** DOM XSS, or DOM-based cross-site scripting, is a flaw where JavaScript in the page reads attacker-controllable data from a source such as location.hash and passes it to a sink such as innerHTML or eval, so the browser runs it as code. The server response can be clean. Fix it with textContent and safe APIs, a sanitizer for HTML, and Trusted Types.

DOM XSS, also called DOM-based cross-site scripting or "xss dom", is a flaw where JavaScript in the page reads data an attacker can influence and passes it to an API that turns strings into HTML or code. The browser then runs it in your site's origin. The server's response can be the same clean file for every request. The flaw sits in the client code. It is a form of [CWE-79](https://cwe.mitre.org/data/definitions/79.html), and [OWASP](https://owasp.org/www-community/attacks/DOM_Based_XSS) and [PortSwigger](https://portswigger.net/web-security/cross-site-scripting/dom-based) both document it as its own type.

This page covers sources and sinks, a worked example, framework escape hatches, a Trusted Types policy, and a test method. For the general topic, read [cross-site scripting](/vulnerabilities/cross-site-scripting/). Test only systems you own or have written authorization to test.

## What is DOM XSS?

DOM XSS happens when a source feeds a sink without safe handling. A source is where the page reads data. A sink is an API that interprets a string as HTML, script or a script URL. [MDN's Trusted Types page](https://developer.mozilla.org/en-US/docs/Web/API/Trusted_Types_API) calls these APIs injection sinks and groups them into HTML sinks, JavaScript sinks and JavaScript URL sinks.

Because the data may never reach the server, server-side filters and logs may not see it. A URL fragment (the part after `#`) is not sent to the server at all. Testers must run the page in a browser to find these flaws.

## How does a DOM XSS attack work?

The page reads a source and writes it to a sink. The example below is fictional.

> **Demonstration only: Harbor Notes, a fictional app. No real customer system was tested.:** The page `https://notes.example.com/welcome` greets the visitor. Its script reads the name from the URL fragment.
> 
> Vulnerable client code:
> 
> ```js
> const name = decodeURIComponent(location.hash.slice(1));
> document.getElementById('greeting').innerHTML = 'Welcome, ' + name;
> ```
> 
> A harmless proof URL:
> 
> ```text
> https://notes.example.com/welcome#
> ```
> 
> The browser never sends the fragment to the server. The script reads `location.hash`, writes it into the page with `innerHTML`, and the image error handler runs in the origin `notes.example.com`. The HTTP response for `/welcome` is identical for every visitor, so a scanner that only reads responses sees nothing.
The fix is one line: write the value as text.

```js
document.getElementById('greeting').textContent = 'Welcome, ' + name;
```

[MDN advises against](https://developer.mozilla.org/en-US/docs/Web/API/Element/innerHTML) `innerHTML` for plain text because it handles raw HTML and can be exposed to XSS.

## Which sources and sinks matter?

Source and sink lists are the core of a DOM XSS review. The lists below are for review work, and they are not complete. Library methods add more.

| Source | Controlled by |
|---|---|
| `location.hash` | Anyone who builds a link |
| `location.search`, `location.pathname` | Anyone who builds a link |
| `document.referrer` | The page that linked to you |
| `window.name` | A page that opened or framed you |
| `postMessage` event data (`event.data`) | Any window that can send a message |
| `localStorage`, `sessionStorage`, cookies read by script | Anything that wrote them earlier, including stored XSS |
| Fetched JSON or HTML from an API | The data's author, for example another user |

| Sink type | Examples | Safe replacement |
|---|---|---|
| HTML | `innerHTML`, `outerHTML`, `insertAdjacentHTML`, `document.write` | `textContent`, `createElement` and `append`, or a sanitizer |
| JavaScript | `eval`, `new Function`, `setTimeout` or `setInterval` with a string | Pass a function, parse JSON with `JSON.parse` |
| Script or navigation URL | `script.src`, `location.href`, `a.href` set from data | Check the scheme and host against an allow list |
| Library methods | jQuery `.html()`, `.append()` with HTML strings | `.text()`, or sanitize first |

A stored value can act as a source. Data saved by one user and fetched by script in another user's page is both stored and DOM-based. See [stored XSS](/vulnerabilities/stored-xss/).

## Which framework features reopen DOM XSS?

Modern frameworks write text safely by default, and each has an escape hatch that brings the sink back. [OWASP's cheat sheet](https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html) lists these escape hatches as a common source of XSS in framework apps.

| Framework | Safe default | Sink to review | Safe call |
|---|---|---|---|
| React | `{value}` in JSX is escaped | `dangerouslySetInnerHTML` | Render text, or sanitize first |
| Angular | Interpolation is sanitized and escaped | `bypassSecurityTrustHtml` and the other `bypassSecurityTrust*` methods, `innerHTML` on `ElementRef` | Use bindings, and `DomSanitizer.sanitize` |
| Vue | Text interpolation is escaped | `v-html` | Text interpolation, or sanitize first |
| jQuery | `.text(value)` | `.html(value)`, `.append(htmlString)` | `.text()` |
| Plain DOM | `textContent` | `innerHTML`, `document.write` | `textContent`, `createElement` |

[React](https://react.dev/reference/react-dom/components/common) and [Angular](https://angular.dev/best-practices/security) document their escape hatches. Check Vue and jQuery in their own docs for the current behavior in your version.

## How do penetration testers find and prove DOM XSS?

Testers trace data from a source to a sink in a real browser, then prove execution. A match on a sink name in the code is a lead, not a finding. The steps follow our method for [web application penetration testing](/web-application-penetration-testing/).

1. Collect the client code. Save every JavaScript bundle and, where the site publishes them, the source maps. Include scripts from third-party hosts that run in the origin.
2. Search the code for sinks from the table above, and for each match note which source feeds it.
3. Use a browser tool that logs sink calls. PortSwigger's [DOM Invader](https://portswigger.net/burp/documentation/desktop/tools/dom-invader) is one, and the browser's own debugger breakpoints on `innerHTML` setters work too.
4. Send a unique marker through each source, in the fragment, the query string, `window.name` and `postMessage`, and watch which sink receives it.
5. Record the context where the marker lands: HTML, an attribute, a JavaScript string or a URL.
6. Send a minimal context-specific proof such as `alert(document.domain)` or `print()` and confirm it runs in a real browser. Record the browser name and version.
7. Run controls. Use a plain-text value, a fragment the code does not read, and a route where the same code is not loaded.
8. Save the evidence: the exact URL or message, the code location of source and sink, the DOM state after execution and a screenshot. Label it "Browser-observed".

The evidence rule is the same as for other XSS types. Script must run in the victim origin. If it ran only in a sandboxed frame, or with a different origin, say so and rate it lower. Tests of `postMessage` handlers also record whether the handler checks `event.origin`, because [MDN](https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage) says a receiving handler must verify the origin of every message.

## How do you fix DOM XSS?

Use safe APIs, validate URLs, sanitize HTML that you must render, and turn on Trusted Types. The order matters: remove the sink first.

Write text with `textContent`, and build elements with the DOM API:

```js
const li = document.createElement('li');
li.textContent = comment.text;
list.append(li);
```

Check a URL before you use it in `href`, `src` or `location`:

```js
function safeUrl(input) {
  try {
    const u = new URL(input, location.origin);
    return ['https:', 'http:'].includes(u.protocol) ? u.href : '#';
  } catch {
    return '#';
  }
}
link.href = safeUrl(params.get('next'));
```

Check the origin in every `postMessage` handler, and treat `event.data` as untrusted:

```js
window.addEventListener('message', (event) => {
  if (event.origin !== 'https://app.example.com') return;
  status.textContent = String(event.data.message ?? '');
});
```

Replace string-to-code calls. Do not call `eval`, `new Function`, or `setTimeout('code string')`. Use `JSON.parse` for data and pass functions to timers.

If you must render user HTML, sanitize it with a maintained library. [DOMPurify](https://github.com/cure53/DOMPurify) is one option:

```js
el.innerHTML = DOMPurify.sanitize(userHtml, { USE_PROFILES: { html: true } });
```

Keep it updated. [CVE-2020-26870](https://nvd.nist.gov/vuln/detail/CVE-2020-26870) was a mutation XSS bug in DOMPurify before 2.0.17.

## How do Trusted Types work, and how do you roll them out?

Trusted Types make the browser reject plain strings at dangerous sinks, so a missed `innerHTML` call throws an error and does not run code. [MDN](https://developer.mozilla.org/en-US/docs/Web/API/Trusted_Types_API) lists the API as Baseline 2026, newly available since February 2026, so it works across the latest browser versions. Older browser versions may not enforce it, so keep the code fixes too.

The [web.dev guide](https://web.dev/articles/trusted-types) describes this rollout:

1. Add a report-only header and collect violations:

```http
Content-Security-Policy-Report-Only: require-trusted-types-for 'script'; report-uri /csp-reports
```

2. Fix each reported sink. Remove the sink, use a library that supports Trusted Types, or create a policy.
3. Create a policy that sanitizes HTML:

```js
if (window.trustedTypes) {
  const policy = trustedTypes.createPolicy('app-html', {
    createHTML: (input) => DOMPurify.sanitize(input),
  });
  el.innerHTML = policy.createHTML(userHtml);
}
```

4. Switch from report-only to enforcement:

```http
Content-Security-Policy: require-trusted-types-for 'script'; script-src 'self'; object-src 'none'; base-uri 'none'
```

A policy is code that decides what is safe, so it needs review like any security code. A permissive default policy that returns its input unchanged turns the protection off. For the other CSP directives, see [Content Security Policy](/vulnerabilities/content-security-policy/) and the [MDN CSP guide](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP).

## What are the limits of a Content Security Policy for DOM XSS?

A CSP without `unsafe-inline` and `unsafe-eval` blocks injected inline script and string-to-code calls, which stops the classic proof. It does not stop every DOM XSS case. An attacker may still change page content, add links or form actions, or feed data into code that the policy already allows. Trusted Types close more of the gap because they act at the sink. Use both.

## What real DOM XSS cases exist?

Two entries from the National Vulnerability Database show the pattern. In both, the fix is in a shared library, so every site that bundles the old version stays exposed until it updates.

| Date | Case | Lesson |
|---|---|---|
| 29 April 2020 | [CVE-2020-11022](https://nvd.nist.gov/vuln/detail/CVE-2020-11022): jQuery 1.12.0 up to 3.5.0 | NVD says passing HTML from untrusted sources, even after sanitizing, to methods such as `.html()` and `.append()` may execute code. Fixed in jQuery 3.5.0. Update bundled copies. |
| 7 October 2020 | [CVE-2020-26870](https://nvd.nist.gov/vuln/detail/CVE-2020-26870): DOMPurify before 2.0.17 | Mutation XSS: a serialize-parse round trip does not necessarily return the original DOM tree. A sanitizer is code that needs updates. |

## DOM XSS testing checklist

1. Save all JavaScript, including third-party scripts and source maps.
2. List sources: hash, search, referrer, `window.name`, `postMessage`, storage, API data.
3. List sinks: HTML, JavaScript and URL sinks, plus library methods.
4. Trace each source to a sink in a browser, with a unique marker.
5. Record the context and prove execution with a minimal proof in the victim origin.
6. Check every `postMessage` handler for an origin check.
7. Check bundled library versions, including jQuery and sanitizers.
8. Read the CSP for `unsafe-inline`, `unsafe-eval`, wildcard hosts, and `require-trusted-types-for`.
9. Run controls with plain text and with routes that do not load the code.
10. Retest after the fix in a real browser with the same URL or message.

## When should you get a professional test for DOM XSS?

Get a professional test when your front end is a single-page app, uses many third-party scripts, handles `postMessage`, or builds HTML from API data. A scanner that reads HTTP responses sees little of the DOM. A tester runs the app in a browser, traces sources to sinks and records browser-observed evidence. Excession Labs does this as part of [web application penetration testing](/web-application-penetration-testing/), with written authorization and fix guidance for each finding. Request a test from that page.

## Questions

### What is a source and a sink in DOM XSS?

A source is a place where the page reads data an attacker can influence, such as location.hash, location.search, document.referrer, window.name or a postMessage event. A sink is an API that turns a string into HTML, script or a script URL, such as innerHTML, document.write or eval. DOM XSS needs a path from a source to a sink.

### How is DOM XSS different from reflected XSS?

In reflected XSS the server puts the input into its HTML response. In DOM XSS the server may return the same clean page every time, and the page's own JavaScript writes the data into the DOM. You find reflected XSS in the HTTP response. You find DOM XSS in the browser, by tracing data in the running page.

### Does a Content Security Policy stop DOM XSS?

It limits it. A policy without unsafe-inline and unsafe-eval blocks injected inline script and string-to-code calls such as eval. It does not stop DOM XSS that changes page content or builds links, and it does not protect code the policy already allows. Use it as a second layer next to safe APIs and Trusted Types.

### What are Trusted Types?

Trusted Types is a browser feature that makes dangerous DOM sinks reject plain strings. You turn it on with the CSP directive require-trusted-types-for 'script'. Code must then pass values through a policy that you define, which reviews or sanitizes them. MDN lists it as Baseline 2026, newly available since February 2026.

### Can server-side encoding prevent DOM XSS?

Not by itself. If the server never sees the data, as with a URL fragment, it cannot encode it. The client code must use safe APIs or sanitize before it writes to a sink. Server-side encoding still matters when the server embeds data in a script block that client code later reads.

## Sources

- [PortSwigger Web Security Academy: DOM-based XSS](https://portswigger.net/web-security/cross-site-scripting/dom-based)
- [OWASP Community: DOM Based XSS](https://owasp.org/www-community/attacks/DOM_Based_XSS)
- [OWASP Cheat Sheet Series: DOM based XSS Prevention](https://cheatsheetseries.owasp.org/cheatsheets/DOM_based_XSS_Prevention_Cheat_Sheet.html)
- [CWE-79: Improper Neutralization of Input During Web Page Generation](https://cwe.mitre.org/data/definitions/79.html)
- [MDN: Trusted Types API](https://developer.mozilla.org/en-US/docs/Web/API/Trusted_Types_API)
- [web.dev: Prevent DOM-based cross-site scripting vulnerabilities with Trusted Types](https://web.dev/articles/trusted-types)
- [MDN: Element.innerHTML](https://developer.mozilla.org/en-US/docs/Web/API/Element/innerHTML)
- [MDN: Window.postMessage()](https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage)
- [MDN: Content Security Policy (CSP)](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP)
- [React docs: Common components (dangerouslySetInnerHTML)](https://react.dev/reference/react-dom/components/common)
- [Angular docs: Security](https://angular.dev/best-practices/security)
- [DOMPurify README](https://github.com/cure53/DOMPurify)
- [NVD: CVE-2020-11022 (jQuery before 3.5.0)](https://nvd.nist.gov/vuln/detail/CVE-2020-11022)
- [NVD: CVE-2020-26870 (DOMPurify before 2.0.17)](https://nvd.nist.gov/vuln/detail/CVE-2020-26870)
