Back to Security
Security
medium
senior

How do you prevent XSS in React applications?

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.

8 min read·~15 min to think through

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.

tsx
<div dangerouslySetInnerHTML={{ __html: userHtml }} />

Bypasses escaping entirely. Required for CMS / rich-text rendering. Sanitize first with DOMPurify:

tsx
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.

tsx
<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:

ts
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).

js
setTimeout(userCode, 100);  // executes user-controlled string as code

Ban via ESLint (no-eval, no-implied-eval). CSP script-src without 'unsafe-eval' makes them throw at runtime.

4. Direct DOM manipulation via refs.

tsx
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).

html
<script>window.__STATE__ = {{state}}</script>

If state contains </script><script>, it breaks out. Encode < as \u003c:

js
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.

ts
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.

js
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 — flag dangerouslySetInnerHTML usage and eval patterns.
  • Type a wrapper: type SafeHtml = { __brand: "safe" } & string and require DOMPurify output 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.

Related questions