Prevent XSS in React apps
React's `{value}` auto-escapes — that's your default. The XSS surface in React is `dangerouslySetInnerHTML`, attacker-controlled URLs in `href`/`src`, `eval`/`new Function`/`setTimeout(string)`, and direct DOM manipulation via refs. Sanitize HTML with DOMPurify, validate URL schemes, ban `eval`-family, and add a strict Content-Security-Policy as a defense-in-depth.
React is safe-by-default for the most common XSS vectors, but the protection has known gaps. The interview question is: what are the gaps, and how do you close them at scale?
What React handles for you.
{value}in JSX auto-escapes<,>,&,",'when rendering text content and attributes.<div>{userInput}</div>is safe.- Attribute injection via JSX is escaped —
<a title={user.bio}>can't break out.
The seven holes in React's safety.
1. dangerouslySetInnerHTML.
<div dangerouslySetInnerHTML={{ __html: userHtml }} />Bypasses escaping entirely. Required for CMS / rich-text rendering. Sanitize first with DOMPurify:
import DOMPurify from "dompurify";
const safe = DOMPurify.sanitize(userHtml);
<div dangerouslySetInnerHTML={{ __html: safe }} />Grep your repo: grep -rn "dangerouslySetInnerHTML" src/. Each instance is a security review.
2. Attacker-controlled URLs in href / src.
<a href={user.profileUrl}>...</a> // user.profileUrl = "javascript:alert(1)"React 16.9+ blocks javascript: URLs and warns. Don't rely on it — versions in production may differ, and the rule isn't applied to all attribute contexts (xlink:href, dynamic SVGs, <iframe srcdoc>). Validate:
function safeUrl(u: string) {
try {
const url = new URL(u, location.origin);
return ["http:", "https:", "mailto:"].includes(url.protocol) ? u : "#";
} catch { return "#"; }
}3. eval, new Function, setTimeout(string), setInterval(string).
setTimeout(userCode, 100); // executes user-controlled string as codeBan via ESLint (no-eval, no-implied-eval). CSP script-src without 'unsafe-eval' makes them throw at runtime.
4. Direct DOM manipulation via refs.
ref.current.innerHTML = userContent;Bypasses React entirely. Same sanitization rule as dangerouslySetInnerHTML. Lint for .innerHTML =.
5. SSR / hydration mismatches.
When server renders user content into the HTML and client re-hydrates, an attacker-supplied value that's valid HTML but bad JS context can run. Server-side escaping has the same rules as client.
6. JSON in script tags (SSR bootstrap data).
<script>window.__STATE__ = {{state}}</script>If state contains </script><script>, it breaks out. Encode < as \u003c:
JSON.stringify(state).replace(/</g, "\\u003c")7. Markdown / template / templating libraries.
The renderer must be configured to sanitize output (most don't by default). react-markdown allows HTML pass-through unless you disable it. Audit every renderer.
The defense-in-depth layer: Content-Security-Policy.
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-{server-random}';
object-src 'none';
base-uri 'self';
frame-ancestors 'self';Even if XSS gets through sanitization, the injected <script> can't run without the matching nonce. Adopt:
'nonce-{random}'per response, attached to your own scripts.- No
'unsafe-inline', no'unsafe-eval'. object-src 'none'blocks Flash/PDF embeds (legacy XSS vectors).base-uri 'self'prevents base-tag injection.- For new Chromium:
require-trusted-types-for 'script'— refuses any string-to-sink write that doesn't go through a vetted policy.
Trusted Types. The 2026 mechanical answer.
const policy = trustedTypes.createPolicy("default", {
createHTML: (s) => DOMPurify.sanitize(s),
});
element.innerHTML = policy.createHTML(userHtml);
// Without the policy, the browser refuses the write.Once enabled via CSP, EVERY innerHTML / document.write / etc., must accept only TrustedHTML. Eliminates entire categories of XSS by construction.
Programmatic enforcement.
eslint-plugin-react+eslint-plugin-security— flagdangerouslySetInnerHTMLusage andevalpatterns.- Type a wrapper:
type SafeHtml = { __brand: "safe" } & stringand requireDOMPurifyoutput to produce it. - CodeQL / Semgrep in CI for XSS sinks across the codebase.
Senior framing. The candidate should name (1) React's defaults, (2) the seven holes, (3) DOMPurify on the unsafe path, (4) CSP + Trusted Types as defense-in-depth, (5) lint + audit tooling as the scale strategy. Sanitization at input AND output is a trap — pick one boundary.
Follow-up questions
- •What does React 16.9+ block in href, and where does it fall short?
- •How does Trusted Types make XSS structurally impossible?
- •Why is CSP defense-in-depth, not a primary defense?
- •How would you build a brand-typed SafeHtml in TypeScript?
Common mistakes
- •Using `dangerouslySetInnerHTML` with raw user input.
- •Allowing `javascript:` URLs in href because React 'handles it'.
- •Server-rendering JSON into a script tag without escaping `<`.
- •Enabling `unsafe-inline` in CSP to make it 'just work'.
Performance considerations
- •DOMPurify is fast; sanitize once at write time, store sanitized.
- •CSP enforcement is browser-level — free at runtime.
Edge cases
- •react-markdown's `rehype-raw` plugin reintroduces HTML — sanitize after.
- •MathJax / KaTeX renderers — verify they don't allow `\href{javascript:...}`.
- •SVG `<use>` with external xlink:href can pull remote markup.
Real-world examples
- •GitHub comments, Notion blocks, Linear's markdown — all sanitized server-side.
- •Twitter's 2010 mXSS worm was a sanitizer/parser mismatch.