Topic 21.2
dangerouslySetInnerHTML and Sanitising HTML
In one line
dangerouslySetInnerHTML puts a string into the page as real HTML, so any untrusted HTML must pass through a sanitiser such as DOMPurify first; better still, render Markdown and rich content as React elements, and let Trusted Types enforce the rule.
Think of it like this
An airport. Most passengers walk through security, where every bag is scanned. dangerouslySetInnerHTML is a side door straight onto the plane. Sometimes you need it (the pilots use it), but anyone who goes through it must be checked by hand first. A sanitiser is that hand check: it opens the bag, keeps the harmless things and throws away anything that could hurt someone.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- dangerouslySetInnerHTML
- React's prop for setting an element's raw HTML from a string. React doesn't check it.
- __html
- The key the object must use:
{ __html: "<b>hi</b>" }. The wrapper makes the choice deliberate. - Sanitiser
- Code that parses HTML and keeps only safe tags and attributes, removing the rest.
- Allow-list
- A list of what is permitted. Anything not on it is removed. Safer than a block-list.
- Block-list
- A list of what is forbidden. Always incomplete, so attackers find what it missed.
- DOMPurify
- The standard JavaScript HTML sanitiser, used in browsers and (with jsdom) on servers.
- Trusted Types
- A browser feature that blocks plain strings at dangerous sinks and only accepts values made by approved policies.
- Policy (Trusted Types)
- A named function made with
trustedTypes.createPolicythat turns strings into trusted values, usually by sanitising them. - Inert document
- A document made by
DOMParseror a<template>: its scripts don't run and its images don't load.
Step by step
01Ask whether you need HTML at all
Most "I need innerHTML" cases don't: bold names, line breaks (white-space: pre-line in CSS), highlighted search terms, lists. Build them with JSX. Reach for dangerouslySetInnerHTML only for HTML that comes from somewhere you don't control the shape of: a CMS, a rich-text editor, email bodies, Markdown converted by a string-based library.
02Sanitise right where you render
Install DOMPurify (npm install dompurify) and call it in the same expression as the prop, so nobody can pass unsanitised HTML by refactoring later. Memoise the call if the HTML is large and re-renders are frequent.
import DOMPurify from "dompurify";
export function SafeHtml({ html }) {
return (
<div
className="prose"
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(html, { USE_PROFILES: { html: true } }) }}
/>
);
}03Tighten the sanitiser for your content
Restrict tags and attributes to what your content uses, and use a hook to add safe link attributes. Hooks run for every node DOMPurify keeps.
import DOMPurify from "dompurify";
DOMPurify.addHook("afterSanitizeAttributes", (node) => {
if (node.tagName === "A") {
node.setAttribute("target", "_blank");
node.setAttribute("rel", "noopener noreferrer");
}
});
export const sanitize = (dirty) =>
DOMPurify.sanitize(dirty, {
ALLOWED_TAGS: ["p", "b", "strong", "i", "em", "ul", "ol", "li", "a", "code", "pre", "br"],
ALLOWED_ATTR: ["href"],
});04Prefer an elements-based Markdown renderer
react-markdown builds React elements, so text is escaped by React. Keep raw HTML off. If you must allow it, sanitise with rehype-sanitize after rehype-raw.
import Markdown from "react-markdown";
import rehypeRaw from "rehype-raw";
import rehypeSanitize from "rehype-sanitize";
// Safe by default: raw HTML in the Markdown is not rendered.
<Markdown>{post.body}</Markdown>
// Only if you need raw HTML: parse it, then sanitise it.
<Markdown rehypePlugins={[rehypeRaw, rehypeSanitize]}>{post.body}</Markdown>05Enforce it with Trusted Types
Send the header from your server or host. Start with Content-Security-Policy-Report-Only to collect violations, fix them, then switch to the enforcing header. The trusted-types directive names the only policies allowed to exist; dompurify is the name DOMPurify uses for its own policy.
Content-Security-Policy: require-trusted-types-for 'script'; trusted-types app-sanitizer dompurifyTry it yourself
- 1
Allow italics
In the sanitiser example, add
<i>nice</i>and<u>under</u>tofromCms. Predict the output:Iis allowed,Uisn't. (Theitag stays; theutag disappears but its textunderstays.) - 2
Smuggle a link
Add
'<a href=" JAVASCRIPT:void(0)">tricky</a>'tofromCms. Predict whetherhrefsurvives. (new URLtrims spaces and lower-cases the scheme, so the protocol isjavascript:and thehrefis removed.) - 3
Break the highlighter on purpose
In the Trusted Types example, change
escapeHtml(text.slice(i))totext.slice(i). Predict what the first result shows now. (The<b>after the match becomes real bold text: one missed escape and user markup gets through, even though a policy was used.)
Code & diagrams
Only one line is logged, and it isn't `this would have run`: the `img` and the `script` were removed while parsing in an inert document, the bad link lost its `href` and `onclick`, and the good link kept its `https:` URL. This is the allow-list idea in about 30 lines. It is **not** complete (DOMPurify also handles SVG, MathML, namespace tricks, `srcset`, CSS and many browser quirks), so don't ship it.
Preview
Console
Nothing logged yet.Expected console output
<p>Hello <b>world</b>!</p><a>bad link</a> <a href="https://react.dev">good link</a>The preview doesn't send the Trusted Types CSP header, so plain strings would also work here; on a site that sends it, only `TrustedHTML` values are accepted. React hands the `TrustedHTML` object straight to `innerHTML`. Notice the policy itself does nothing (`(s) => s`): it's only safe because `highlight` escapes every piece of user text. A policy is a promise that you checked; keep the code that feeds it small and reviewed.
Preview
Console
Nothing logged yet.Expected console output
Trusted Types in this browser: true
policy output is TrustedHTML: true
React <b><mark>hooks</mark></b> guide
<mark>Hooks</mark> & "effects" explainedThis is what react-markdown does at full scale: parse the source into a tree, then create elements. The raw `<img>` stays text because it's a string child, and the `javascript:` link becomes plain text because the URL check failed. There's no `dangerouslySetInnerHTML` anywhere.
Preview
Console
Nothing logged yet.Expected console output
Read the <strong>docs</strong> at <a href="https://react.dev">react.dev</a>.
Raw HTML stays text: <img src=x onerror="console.log(1)">
Bad link: click# Page sent: Content-Security-Policy: require-trusted-types-for 'script'; trusted-types app-sanitizer
> document.getElementById("d").innerHTML = "<b>x</b>";
This document requires 'TrustedHTML' assignment. The action has been blocked.
Uncaught TypeError: Failed to set the 'innerHTML' property on 'Element': This document requires 'TrustedHTML' assignment.
> trustedTypes.createPolicy("other", { createHTML: (s) => s });
Creating a TrustedTypePolicy named 'other' violates the following Content Security policy directive: "trusted-types app-sanitizer". The action has been blocked.
Uncaught TypeError: Failed to execute 'createPolicy' on 'TrustedTypePolicyFactory': Policy "other" disallowed.
# A React component with dangerouslySetInnerHTML={{ __html: "<b>x</b>" }} (a plain string)
# fails the same way when React commits it: the TypeError comes from the innerHTML assignment.Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Pass a plain string
Write <div dangerouslySetInnerHTML={post.html} />, forgetting the object wrapper.
Break #2
Children and raw HTML together
Write <div dangerouslySetInnerHTML={{ __html: html }}>Loading…</div>.
Break #3
Sanitise with a regex
Clean HTML with html.replace(/<script.*?<\/script>/gi, "") before rendering it.
Myth vs fact
Myth
Sanitising on the server once is enough.
Fact
Server sanitisation helps, but data also arrives from older records, imports and other services, and sanitisers get bug fixes over time. Sanitise right before rendering in the browser too, where the browser's own parser is used.
Myth
The name dangerously means React adds extra checks.
Fact
React does no checking at all. The name is only a warning to you and your reviewers.
Myth
Trusted Types sanitises HTML for you.
Fact
Trusted Types only forces every HTML string to go through a policy you wrote. If the policy does (s) => s on raw user input, you're still vulnerable. Pair it with DOMPurify.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
React DOM sets
domElement.innerHTML = props.dangerouslySetInnerHTML.__htmlwithout stringifying, which is why aTrustedHTMLobject passes through. On updates, React compares the__htmlvalues and only rewritesinnerHTMLwhen the value changed, so re-renders with the same string don't reset the content (or the user's selection inside it). - ▸
During hydration React keeps the server's HTML inside a
dangerouslySetInnerHTMLelement; it doesn't patch it. The development build compares it with the client's__htmland reports a hydration mismatch, but in production the user simply sees the server's version until__htmlchanges. So if the server and the client sanitise differently, the server's sanitiser decides what users see on first load: keep them identical. - ▸
DOMPurify relies on the browser's own parser, which is why it's robust against parser quirks (mutation XSS). On the server, use
isomorphic-dompurifyor DOMPurify with jsdom, and remember the browser may still parse the result slightly differently: sanitise on the client too. - ▸
Next.js and React don't need a Trusted Types policy to run, but some libraries (older analytics, some editors) assign strings to sinks. Report-only mode finds them. A policy named
defaultcatches all plain-string assignments as a migration aid; treat it as temporary.
Remember this
- 1
dangerouslySetInnerHTML={{ __html: html }}tells React: set this element'sinnerHTMLto this string. The browser parses it as HTML, so tags become elements and event-handler attributes become code. React does no checking at all. The element must have no children (React throwsCan only set one of \children\or \props.dangerouslySetInnerHTML\.), and the value must be an object with an__htmlkey, or React throws\props.dangerouslySetInnerHTML\must be in the form \{__html: ...}\. - 2
Why the scary name and the odd object? Both are deliberate friction. The long name makes the line stand out in code review and in a search. The
{ __html }wrapper means a plain string can never be passed by accident: someone has to build that object on purpose, ideally right next to the sanitiser call. React's docs suggest creating the object where the HTML is sanitised, so the unsafe value never travels far. - 3
Sanitising means parsing untrusted HTML and keeping only what is on an allow-list (tags and attributes you know are harmless:
p,b,a hrefwith anhttps:link…), removing everything else. An allow-list is the only approach that works: a block-list ("remove<script>") always misses something, because HTML has hundreds of tags, attributes and parsing quirks. Never write a regex sanitiser. In real apps use DOMPurify, a small, widely used, actively maintained library:DOMPurify.sanitize(dirty)returns safe HTML, and options such asALLOWED_TAGSand hooks let you tighten it (for example, addingrel="noopener noreferrer"to links). Sanitise on the client right before rendering, even if the server also sanitised, and keep DOMPurify up to date because its fixes are security fixes. - 4
Markdown renderers come in two kinds. Libraries like react-markdown turn Markdown into React elements, never into an HTML string, so ordinary text stays escaped; raw HTML inside the Markdown is ignored by default, and unsafe link protocols are removed by its default
urlTransform. If you enable raw HTML with therehype-rawplugin, addrehype-sanitizeafter it. Libraries like marked or markdown-it produce an HTML string, which you can only render withdangerouslySetInnerHTML, so you must run DOMPurify on their output. Prefer the elements approach: there is nothing to sanitise. - 5
Trusted Types is a browser feature that makes the sinks themselves safe. You send a Content Security Policy header
require-trusted-types-for 'script', and from then on assigning a plain string toinnerHTML(and other sinks such asevaland scriptsrc) throws aTypeError. The only way to produce HTML is through a named policy you create withtrustedTypes.createPolicy(name, { createHTML }), and a second directive (trusted-types my-policy dompurify) lists which policy names may exist. So every HTML string in the app must pass through code you chose, usually DOMPurify (RETURN_TRUSTED_TYPE: truemakes it return aTrustedHTMLobject). React passes the__htmlvalue straight toinnerHTML, so aTrustedHTMLobject works withdangerouslySetInnerHTML. Chromium-based browsers (Chrome, Edge) have supported it since 2020 and other browsers have been adding it, so always feature-detectwindow.trustedTypes; in a browser without it the header does nothing and your sanitiser is the only wall. - 6
How to verify: grep for
dangerouslySetInnerHTMLand check that every one receives sanitised or self-built HTML; feed each one a harmless marker like<img src=x onerror="console.log('ran')">in a test and assert nothing logs and noimgkeeps anonerror; turn on Trusted Types in report-only mode first (Content-Security-Policy-Report-Only) to find every sink without breaking the app, then enforce it.
Explain it without notes
Why is the prop called dangerouslySetInnerHTML, and why does it take an object instead of a string?
Why must a sanitiser use an allow-list, and why should you use DOMPurify instead of writing one?
Compare react-markdown with marked plus DOMPurify for rendering user Markdown.
What are Trusted Types, and how do they work with React?
Practice
Write a SafeHtml component that sanitises with DOMPurify, allows only p, b, i, a and br, and makes every link open in a new tab safely.
A Note component uses dangerouslySetInnerHTML only to keep line breaks (text.replace(/\n/g, "<br>")). Rewrite it without raw HTML.
Write a Vitest test that proves SafeHtml removes an event-handler attribute.
Trade-offs
- ↔
Rendering rich content as elements (react-markdown, a block-based editor format) is safest and gives you full control, but it costs a parser in the bundle and limits authors to what you support. Sanitised HTML is flexible but you must maintain the allow-list and the dependency.
- ↔
Trusted Types catches every sink, including ones in libraries, but some third-party scripts break under it; plan a report-only period and budget time to replace or wrap them.
Done when you can
I can explain what
dangerouslySetInnerHTMLdoes and why it has that name and shape.I sanitise untrusted HTML with DOMPurify right where it's rendered.
I can explain why allow-lists work and block-lists and regexes don't.
I prefer element-based Markdown rendering and know when sanitising is still needed.
I can describe Trusted Types, its CSP directives and how React works with it.
I can test that a sanitiser removes event handlers and unsafe links.