Command Palette

Search for a command to run...

Hectal
PHASE 21Advanced ~32 min· topic 2 of 6

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.createPolicy that turns strings into trusted values, usually by sanitising them.
Inert document
A document made by DOMParser or 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.

SafeHtml.jsxwhole filejsx
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.

sanitize.jswhole filejavascript
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.

Post.jsxwhole filejsx
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>
Prefer an elements-based Markdown rendererdiagram
Rendering diagram…

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.

response headerswhole filehttp
Content-Security-Policy: require-trusted-types-for 'script'; trusted-types app-sanitizer dompurify

Try it yourself

  1. 1

    Allow italics

    In the sanitiser example, add <i>nice</i> and <u>under</u> to fromCms. Predict the output: I is allowed, U isn't. (The i tag stays; the u tag disappears but its text under stays.)

  2. 2

    Smuggle a link

    Add '<a href=" JAVASCRIPT:void(0)">tricky</a>' to fromCms. Predict whether href survives. (new URL trims spaces and lower-cases the scheme, so the protocol is javascript: and the href is removed.)

  3. 3

    Break the highlighter on purpose

    In the Trusted Types example, change escapeHtml(text.slice(i)) to text.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

A tiny allow-list sanitiser (for learning; real apps use DOMPurify) New tab

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

Press Run to see the page.

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>
Trusted Types: HTML only from a named policy New tab

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

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

Trusted Types in this browser: true
policy output is TrustedHTML: true
React &lt;b&gt;<mark>hooks</mark>&lt;/b&gt; guide
<mark>Hooks</mark> &amp; "effects" explained
Markdown as React elements: nothing to sanitise New tab

This 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

Press Run to see the page.

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: &lt;img src=x onerror="console.log(1)"&gt;
Bad link: click
What the browser says when Trusted Types is enforced (Chromium console)bash
# 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.

terminal
$ Console
── what you'll see ──
Uncaught Error: `props.dangerouslySetInnerHTML` must be in the form `{__html: ...}`. Please visit https://react.dev/link/dangerously-set-inner-html for more information.

Break #2

Children and raw HTML together

Write <div dangerouslySetInnerHTML={{ __html: html }}>Loading…</div>.

terminal
$ Console
── what you'll see ──
Uncaught Error: Can only set one of `children` or `props.dangerouslySetInnerHTML`.

Break #3

Sanitise with a regex

Clean HTML with html.replace(/<script.*?<\/script>/gi, "") before rendering it.

terminal
$ Page
── what you'll see ──
A comment containing `<img src=x onerror="console.log('this would have run')">` still logs `this would have run`. The regex only knew about `<script>`.

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.__html without stringifying, which is why a TrustedHTML object passes through. On updates, React compares the __html values and only rewrites innerHTML when 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 dangerouslySetInnerHTML element; it doesn't patch it. The development build compares it with the client's __html and reports a hydration mismatch, but in production the user simply sees the server's version until __html changes. 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-dompurify or 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 default catches all plain-string assignments as a migration aid; treat it as temporary.

Remember this

  1. 1

    dangerouslySetInnerHTML={{ __html: html }} tells React: set this element's innerHTML to 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 throws Can only set one of \children\ or \props.dangerouslySetInnerHTML\.), and the value must be an object with an __html key, or React throws \props.dangerouslySetInnerHTML\ must be in the form \{__html: ...}\.

  2. 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. 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 href with an https: 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 as ALLOWED_TAGS and hooks let you tighten it (for example, adding rel="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. 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 the rehype-raw plugin, add rehype-sanitize after it. Libraries like marked or markdown-it produce an HTML string, which you can only render with dangerouslySetInnerHTML, so you must run DOMPurify on their output. Prefer the elements approach: there is nothing to sanitise.

  5. 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 to innerHTML (and other sinks such as eval and script src) throws a TypeError. The only way to produce HTML is through a named policy you create with trustedTypes.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: true makes it return a TrustedHTML object). React passes the __html value straight to innerHTML, so a TrustedHTML object works with dangerouslySetInnerHTML. Chromium-based browsers (Chrome, Edge) have supported it since 2020 and other browsers have been adding it, so always feature-detect window.trustedTypes; in a browser without it the header does nothing and your sanitiser is the only wall.

  6. 6

    How to verify: grep for dangerouslySetInnerHTML and 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 no img keeps an onerror; 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

01

Why is the prop called dangerouslySetInnerHTML, and why does it take an object instead of a string?

02

Why must a sanitiser use an allow-list, and why should you use DOMPurify instead of writing one?

03

Compare react-markdown with marked plus DOMPurify for rendering user Markdown.

04

What are Trusted Types, and how do they work with React?

Practice

01

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.

02

A Note component uses dangerouslySetInnerHTML only to keep line breaks (text.replace(/\n/g, "<br>")). Rewrite it without raw HTML.

03

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 dangerouslySetInnerHTML does 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.