XSS in innerHTML and dangerouslySetInnerHTML

If you render attacker-controlled HTML, you are one bad string away from XSS.

Two APIs show up constantly in real apps:

  • the browser DOM API: element.innerHTML
  • the React escape hatch: dangerouslySetInnerHTML

Both do the same dangerous thing: they parse a string as HTML and inject it into the page. If that string contains active content, your app can execute attacker-supplied JavaScript.

This is the reference I wish more teams had pinned in their repos.

The short version

Treat these APIs as HTML execution sinks.

If the content is not fully trusted, do one of these instead:

  • use textContent / innerText for plain text
  • build DOM nodes with safe APIs like createElement
  • sanitize HTML before injection
  • back it up with a strict Content Security Policy

If you remember one rule, use this:

Never pass raw user input into innerHTML or dangerouslySetInnerHTML.

Why these APIs are dangerous

Browsers parse HTML, not just text. That means attacker input can become:

  • elements
  • attributes
  • event handlers
  • URLs
  • SVG or MathML payloads
  • DOM structure that changes page behavior

Classic example:

const bio = `<img src=x onerror=alert('XSS')>`;
profile.innerHTML = bio;

That is not “just a string.” It becomes a real <img> element with a live onerror handler.

React’s version is explicit but not safer by itself:

function Bio({ html }) {
  return <div dangerouslySetInnerHTML={{ __html: html }} />;
}

If html is attacker-controlled, you have the same problem.

Vulnerable innerHTML patterns

Direct assignment from user input

const comment = new URLSearchParams(location.search).get("comment");
document.getElementById("output").innerHTML = comment;

Payload:

?comment=<img src=x onerror=alert(1)>

Template literals with mixed trusted and untrusted values

This is common because it looks neat.

const username = apiResponse.username;
card.innerHTML = `
  <div class="user-card">
    <h2>${username}</h2>
  </div>
`;

If username is </h2><script>alert(1)</script>, you just changed the DOM structure.

Building lists from database content

Stored XSS is usually worse than reflected XSS because it keeps firing.

posts.innerHTML = postsFromDb.map(post => `
  <article>
    <h3>${post.title}</h3>
    <div>${post.body}</div>
  </article>
`).join("");

If attackers can save HTML into post.body, every viewer becomes a target.

Vulnerable dangerouslySetInnerHTML patterns

Rendering CMS or Markdown output without sanitization

export default function Article({ contentHtml }) {
  return <div dangerouslySetInnerHTML={{ __html: contentHtml }} />;
}

A lot of teams assume “it came from our CMS” means “it is safe.” That only holds if the CMS sanitizes correctly and you trust every editor and integration.

Sanitizing on one route but not another

I’ve seen this a lot in React apps:

function SafePreview({ html }) {
  return <div dangerouslySetInnerHTML={{ __html: sanitize(html) }} />;
}

function LegacyPreview({ html }) {
  return <div dangerouslySetInnerHTML={{ __html: html }} />;
}

One safe component and one old unsafe component is enough for an incident.

Safe alternatives first

For plain text, use textContent

If you do not need HTML formatting, stop using HTML sinks.

const comment = new URLSearchParams(location.search).get("comment");
document.getElementById("output").textContent = comment;

React already escapes text content by default:

function Comment({ text }) {
  return <p>{text}</p>;
}

That is safe because React treats text as text, not HTML.

Build DOM nodes explicitly

const li = document.createElement("li");
li.textContent = user.name;
list.appendChild(li);

This avoids HTML parsing entirely.

Allow a tiny formatting subset with sanitization

If you really need rich text like <b>, <i>, <p>, <a>, sanitize before rendering.

Sanitizing HTML before injection

If you must support user-supplied HTML, sanitize it with a well-maintained sanitizer and a strict allowlist.

Browser-side example with DOMPurify official docs:

<div id="preview"></div>
<script type="module">
  import DOMPurify from '/static/dompurify.es.mjs';

  const dirty = `
    <p>Hello <strong>world</strong></p>
    <img src=x onerror=alert(1)>
    <a href="javascript:alert(2)">click me</a>
  `;

  const clean = DOMPurify.sanitize(dirty, {
    ALLOWED_TAGS: ['p', 'strong', 'em', 'ul', 'ol', 'li', 'a', 'code', 'pre', 'br'],
    ALLOWED_ATTR: ['href', 'title'],
    ALLOW_DATA_ATTR: false
  });

  document.getElementById('preview').innerHTML = clean;
</script>

React example:

import DOMPurify from 'dompurify';

export function SafeHtml({ html }) {
  const clean = DOMPurify.sanitize(html, {
    ALLOWED_TAGS: ['p', 'strong', 'em', 'ul', 'ol', 'li', 'a', 'code', 'pre', 'br'],
    ALLOWED_ATTR: ['href', 'title']
  });

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

My rule: sanitize at the boundary where HTML becomes HTML. Don’t assume upstream systems did it right.

Sanitization gotchas

Sanitization is not “strip <script> and call it done.” Real payloads use:

  • event handlers like onerror
  • javascript: URLs
  • SVG
  • malformed markup
  • nested elements
  • browser parsing quirks

Bad sanitizer example:

function fakeSanitize(html) {
  return html.replace(/<script.*?>.*?<\/script>/gi, '');
}

This misses basically everything that matters.

Another common mistake is sanitizing, then modifying the sanitized HTML unsafely:

const clean = sanitize(userHtml);
container.innerHTML = `<div class="wrapper">${clean}${userNickname}</div>`;

If userNickname is untrusted, you reintroduced XSS after sanitization.

Trusted vs untrusted HTML

I split HTML sources into three buckets:

1. Trusted static HTML

Hardcoded by developers in source code.

box.innerHTML = "<p>Maintenance starts at 2 AM UTC.</p>";

Usually acceptable, though I still prefer DOM APIs or templates where practical.

2. Operationally trusted HTML

Generated by your own renderer or CMS, with strong sanitization and clear ownership.

This can be okay, but verify the pipeline. “Trusted” tends to drift over time.

3. Untrusted HTML

Anything from users, query params, API fields, support tickets, imports, webhooks, partner feeds, AI output, or old database records.

Assume hostile until sanitized.

AI-generated HTML especially deserves suspicion. Models happily produce inline handlers, weird attributes, and unsafe links if prompted badly.

Safer reusable wrappers

If your app needs HTML rendering, centralize it.

Vanilla JS wrapper

import DOMPurify from '/static/dompurify.es.mjs';

export function setSafeHTML(element, dirtyHtml) {
  const clean = DOMPurify.sanitize(dirtyHtml, {
    ALLOWED_TAGS: ['p', 'a', 'strong', 'em', 'code', 'pre', 'ul', 'ol', 'li', 'br'],
    ALLOWED_ATTR: ['href', 'title'],
    ALLOW_DATA_ATTR: false
  });

  element.innerHTML = clean;
}

Usage:

setSafeHTML(document.getElementById('content'), apiResponse.html);

React wrapper

import DOMPurify from 'dompurify';

export function SanitizedHtml({ html, className }) {
  const clean = DOMPurify.sanitize(html, {
    ALLOWED_TAGS: ['p', 'a', 'strong', 'em', 'code', 'pre', 'ul', 'ol', 'li', 'br'],
    ALLOWED_ATTR: ['href', 'title']
  });

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

Then ban raw dangerouslySetInnerHTML in code review except inside this component.

That one change removes a lot of chaos.

Defense in depth with CSP

CSP will not fix unsafe HTML injection, but it can make exploitation harder, especially by blocking inline scripts and reducing script execution paths.

A decent baseline looks like this:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-r4nd0m';
  object-src 'none';
  base-uri 'none';
  frame-ancestors 'none';

For implementation details, see CSP Guide.

A few honest caveats:

  • CSP is a backup, not a substitute for sanitization
  • weak CSPs with 'unsafe-inline' lose a lot of value
  • event-handler payloads may be blocked by strong CSP, but you should not rely on that as your primary control

Code review checklist

When I review code for this issue, I grep for:

  • innerHTML
  • outerHTML
  • insertAdjacentHTML
  • dangerouslySetInnerHTML
  • jQuery .html()

Then I ask:

  1. Where does this string come from?
  2. Can any user influence it directly or indirectly?
  3. Is it sanitized right before injection?
  4. Is the sanitizer configured with a minimal allowlist?
  5. Are URLs restricted to safe schemes?
  6. Is there a CSP in place?
  7. Can we replace this with text rendering instead?

Copy-paste safe patterns

Safe plain text in vanilla JS

output.textContent = userInput;

Safe plain text in React

<div>{userInput}</div>

Safe HTML in vanilla JS

const clean = DOMPurify.sanitize(dirtyHtml);
element.innerHTML = clean;

Safe HTML in React

const clean = DOMPurify.sanitize(dirtyHtml);
return <div dangerouslySetInnerHTML={{ __html: clean }} />;

Unsafe pattern to ban

element.innerHTML = userInput;
return <div dangerouslySetInnerHTML={{ __html: userInput }} />;

Final rule of thumb

If the value should be treated as text, render text.

If it must be treated as HTML, sanitize it immediately before innerHTML or dangerouslySetInnerHTML.

And if someone says “it’s only internal content,” I’d still verify it. Internal content has a funny habit of becoming attacker-controlled six months later.