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/innerTextfor 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
innerHTMLordangerouslySetInnerHTML.
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:
innerHTMLouterHTMLinsertAdjacentHTMLdangerouslySetInnerHTML- jQuery
.html()
Then I ask:
- Where does this string come from?
- Can any user influence it directly or indirectly?
- Is it sanitized right before injection?
- Is the sanitizer configured with a minimal allowlist?
- Are URLs restricted to safe schemes?
- Is there a CSP in place?
- 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.