React 98 ⚛️ Preventing XSS and Injection Vulnerabilities in React
React’s JSX escaping is a powerful default, but it is a narrow one. Every value interpolated inside curly braces—{userInput}—is converted to a text node, so a string like <script>alert(1)</script> renders as literal characters rather than executing . This single default has prevented more reflected and stored XSS than almost any other framework decision. The problem is that developers often generalize this protection to places it does not reach. React does not sanitize raw HTML rendered through dangerouslySetInnerHTML, it does not validate URL schemes in href or src attributes, and it does not protect values written directly to the DOM through refs . These three surfaces are where React XSS vulnerabilities live, and they are the subject of this chapter.
Key point: React escapes text interpolation by design. It does not escape raw HTML, URL schemes, or imperative DOM writes. The dangerouslySetInnerHTML prop is named that way because the React team wants every reviewer to stop at it—the string you pass is the string that executes .
Why React’s protection has hard edges
The raw HTML escape hatch problem. The dangerouslySetInnerHTML prop maps directly to the DOM’s innerHTML setter. It exists because some content—CMS articles, markdown output, rich-text editor results—is authored as HTML and cannot be rendered as plain text. The prop bypasses React’s escaping entirely by design. If the value passed to it contains a <script> tag or an onerror attribute, the browser executes it . The name is a warning, not a suggestion.
The URL scheme problem. React treats href and src values as opaque strings. It escapes HTML entities, which prevents an attribute breakout, but it does not parse the URL or validate its scheme. A value of javascript:alert(document.cookie) passes through React’s escaping unmodified and executes when the user clicks the link . This is a live vector precisely because developers assume “JSX escapes everything” and stop checking attributes.
The markdown library problem. A common pattern is to convert markdown to HTML with a library and pass the result to dangerouslySetInnerHTML. The assumption is that the markdown library handles sanitization. Most do not. A markdown renderer converts syntax to HTML; it does not strip dangerous tags or event handlers. The output must be sanitized separately before it reaches the DOM sink .
The supply chain problem. React applications are built on npm dependencies. A compromised package can inject malicious code that runs in the browser, bypassing every React-level protection. The September 2025 npm worm that compromised over 500 packages demonstrated that dependency security is part of XSS prevention, not a separate concern .
a. Sanitizing Raw HTML with DOMPurify
When raw HTML rendering is unavoidable, the value must be sanitized at the point of render, not at the point of storage. Sanitizing at write time rots: sanitizer bypasses get patched over time, old rows stay dirty, and new ingestion paths skip the write-path hook . DOMPurify is the standard sanitizer because it parses the HTML string into a DOM tree, strips scriptable elements and event-handler attributes, and serializes the result back to a safe string .
import DOMPurify from 'dompurify';
function Article({ html }) {
// Sanitize at render time, every time
const clean = DOMPurify.sanitize(html, {
// Restrictive allowlist for CMS content
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'a', 'ul', 'ol', 'li'],
ALLOWED_ATTR: ['href', 'title'],
// Allow only safe URL schemes
ALLOWED_URI_REGEXP: /^https?:|^mailto:/i,
});
return <div dangerouslySetInnerHTML={{ __html: clean }} />;
}
The allowlist should be as restrictive as the content requires. A comments section needs only b, i, and a; a CMS article may need headings and lists. The ALLOWED_URI_REGEXP option prevents javascript: URLs inside href attributes from surviving sanitization .
A production pattern wraps the sanitizer in a single module that every component uses. Two call sites drift, and the second one quietly loses FORBID_ATTR . A SafeHtml component that sanitizes internally and accepts only a TrustedHtml branded type makes the safe path the only path .
b. Validating URL Schemes Before They Reach Attributes
React does not validate URL schemes. The fix is to validate at the data boundary before the value reaches JSX. The pattern is an allowlist: accept https:, http:, and mailto:, and reject everything else, including javascript:, data:, and vbscript: .
function safeHref(url) {
try {
const parsed = new URL(url, window.location.origin);
// Allowlist only safe schemes
if (['https:', 'http:', 'mailto:'].includes(parsed.protocol)) {
return url;
}
} catch {
// Malformed URL: fail closed
}
return '#';
}
function ExternalLink({ href, children }) {
return (
<a href={safeHref(href)} rel="noopener noreferrer" target="_blank">
{children}
</a>
);
}
The rel="noopener noreferrer" attribute is a separate concern from XSS, but it belongs in the same component. External links without it allow the destination page to manipulate the opener window through window.opener . The validation and the rel attribute together make the link component safe.
For src attributes on <img> and <iframe>, the same scheme validation applies. An <img src={userUrl}> with a javascript: scheme does not execute in modern browsers, but data: URLs can contain SVG with embedded scripts. The allowlist should be explicit, not a blocklist.
c. Avoiding Dangerous Patterns Beyond the Obvious Sinks
The three surfaces—dangerouslySetInnerHTML, URL attributes, and imperative DOM writes—cover the direct sinks. Two additional patterns are worth treating as dangerous by default.
Rendering user content as components. Passing untrusted data into React.createElement type positions or dynamic component names invites injection. The component tree should be static and driven by trusted logic; only data should come from users . A user who can control which component renders can potentially control the props passed to it, including dangerouslySetInnerHTML.
Inline script serialization. Server-side rendering frameworks serialize data into the HTML as inline scripts for hydration. If that serialization is not escaped, a value containing </script><script>alert(1)</script> breaks out of the script context and executes . Next.js added escaping to its beforeInteractive script serialization for exactly this reason . When serializing data into an inline script, escape <, >, &, and \u2028/\u2029 before embedding.
Content Security Policy as a second layer. CSP does not prevent XSS, but it limits the damage. A policy that blocks inline scripts and disallows unsafe-inline in script-src means an injected onerror handler will not execute even if a sanitizer misses it . The policy should be deployed in report-only mode first to collect violations, then enforced after the legitimate sources are identified.
Complete Example Session
// ============================================
// PART 1: THE SAFE DEFAULT
// ============================================
// JSX interpolation escapes text automatically
function Comment({ body }) {
// body is rendered as text, not HTML
// <script>alert(1)</script> appears literally
return <p>{body}</p>;
}
// ============================================
// PART 2: THE DANGEROUS SINK
// ============================================
// dangerouslySetInnerHTML bypasses escaping
// NEVER use with unsanitized input
function UnsafeArticle({ html }) {
return <div dangerouslySetInnerHTML={{ __html: html }} />;
}
// ============================================
// PART 3: SANITIZE BEFORE THE SINK
// ============================================
import DOMPurify from 'dompurify';
const POLICY = {
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'a', 'ul', 'ol', 'li'],
ALLOWED_ATTR: ['href'],
ALLOWED_URI_REGEXP: /^https?:|^mailto:/i,
};
function SafeArticle({ html }) {
const clean = DOMPurify.sanitize(html, POLICY);
return <div dangerouslySetInnerHTML={{ __html: clean }} />;
}
// ============================================
// PART 4: URL SCHEME VALIDATION
// ============================================
function safeHref(url) {
try {
const parsed = new URL(url, window.location.origin);
if (['https:', 'http:', 'mailto:'].includes(parsed.protocol)) {
return url;
}
} catch {
// Malformed URL
}
return '#';
}
function SafeLink({ href, children }) {
return <a href={safeHref(href)}>{children}</a>;
}
// ============================================
// PART 5: SINGLE SANITIZER MODULE
// ============================================
// lib/sanitize.js — the only module allowed to call DOMPurify
// Every component imports this, not DOMPurify directly
// This prevents policy drift across call sites
export function sanitizeHtml(dirty) {
return DOMPurify.sanitize(dirty, POLICY);
}
// ============================================
// PART 6: BRANDED TYPES FOR TYPE SAFETY
// ============================================
// TypeScript can enforce that only sanitized HTML
// reaches the dangerous sink
export type TrustedHtml = string & { readonly __brand: 'TrustedHtml' };
export function sanitizeHtml(content: string): TrustedHtml {
return DOMPurify.sanitize(content, POLICY) as TrustedHtml;
}
function SafeHtml({ content }: { content: TrustedHtml }) {
return <div dangerouslySetInnerHTML={{ __html: content }} />;
}
// SafeHtml can only be called with sanitizeHtml() output
// TypeScript errors if raw string is passed
// ============================================
// PART 7: MARKDOWN PIPELINE SANITIZATION
// ============================================
// Markdown libraries convert syntax to HTML
// They do NOT sanitize by default
import { marked } from 'marked';
function Markdown({ source }) {
// Convert markdown to HTML
const rawHtml = marked.parse(source);
// Sanitize the HTML output before rendering
const clean = DOMPurify.sanitize(rawHtml, POLICY);
return <div dangerouslySetInnerHTML={{ __html: clean }} />;
}
// ============================================
// PART 8: AVOIDING IMPERATIVE DOM SINKS
// ============================================
// React refs can write to the DOM directly
// Do not use innerHTML on refs with untrusted data
function BadRef({ text }) {
const ref = useRef(null);
useEffect(() => {
// ❌ ref.current.innerHTML = text;
// ✅ Use textContent for plain text
ref.current.textContent = text;
}, [text]);
return <div ref={ref} />;
}
// ============================================
// PART 9: INLINE SCRIPT ESCAPING (SSR)
// ============================================
// When serializing data into an inline script
// for hydration, escape dangerous characters
function escapeForScript(json) {
return json
.replace(/</g, '\\u003c')
.replace(/>/g, '\\u003e')
.replace(/&/g, '\\u0026');
}
// const script = `<script>window.__DATA__ = ${escapeForScript(JSON.stringify(data))}</script>`;
// ============================================
// PART 10: CSP AS DEFENSE IN DEPTH
// ============================================
// Content-Security-Policy header
// Blocks inline scripts and event handlers
// even if sanitizer misses them
// script-src 'self' 'nonce-{random}';
// object-src 'none';
// base-uri 'self';
The ten parts move from the safe default through the dangerous sink to the mitigation patterns. Part 6 shows how TypeScript can enforce the sanitization contract at compile time. Part 9 addresses the SSR hydration serialization sink that is easy to overlook. Part 10 adds a layer that does not depend on developer discipline.
Quick Reference
XSS Surfaces in React
| Surface | Escaped by React | Mitigation |
|---|---|---|
{text} in JSX | Yes | None needed |
dangerouslySetInnerHTML | No | DOMPurify at render time |
href={url} | HTML entities only | Scheme allowlist |
src={url} | HTML entities only | Scheme allowlist |
ref.current.innerHTML | No | Avoid; use textContent |
| Inline script serialization | Depends on framework | Escape <, >, & |
DOMPurify Configuration
| Option | Purpose | Example |
|---|---|---|
ALLOWED_TAGS | Restrict permitted elements | ['p', 'a', 'strong'] |
ALLOWED_ATTR | Restrict permitted attributes | ['href', 'title'] |
ALLOWED_URI_REGEXP | Restrict URL schemes | /^https?:/i |
FORBID_ATTR | Block specific attributes | ['style', 'onerror'] |
URL Scheme Allowlist
| Scheme | Safe | Action |
|---|---|---|
https: | Yes | Allow |
http: | Yes (with caution) | Allow |
mailto: | Yes | Allow |
javascript: | No | Reject |
data: | No (can contain SVG/script) | Reject |
vbscript: | No | Reject |
Best Practices
✅ Do This:
// Sanitize at render time
const clean = DOMPurify.sanitize(html); // ✅
// Validate URL schemes with an allowlist
if (['https:', 'mailto:'].includes(parsed.protocol)) return url; // ✅
// Use a single sanitizer module across the codebase
import { sanitizeHtml } from './lib/sanitize'; // ✅
// Prefer textContent over innerHTML for refs
ref.current.textContent = text; // ✅
// Escape data serialized into inline scripts
.replace(/</g, '\\u003c') // ✅
❌ Don’t Do This:
// Pass unsanitized HTML to dangerouslySetInnerHTML
<div dangerouslySetInnerHTML={{ __html: userHtml }} /> // ❌
// Trust markdown library output without sanitizing
<div dangerouslySetInnerHTML={{ __html: marked(md) }} /> // ❌
// Use blocklist regex as a sanitizer
html.replace(/<script>/g, '') // ❌ (bypassable)
// Render user content as a component type
<DynamicComponent {...userProps} /> // ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
XSS through dangerouslySetInnerHTML | Raw HTML passed without sanitization | DOMPurify at render time |
javascript: link executes | URL not validated | Scheme allowlist |
| Markdown output executes | Library does not sanitize | Sanitize after markdown conversion |
| Sanitizer bypassed over time | Sanitized at write, not render | Sanitize every render |
Ref innerHTML injection | Direct DOM write | Use textContent |
| Hydration script breakout | No escaping in serialization | Escape <, >, & |
Real-World Examples
1. Safe Text Interpolation
<p>{comment.body}</p>
2. Sanitized HTML Rendering
const clean = DOMPurify.sanitize(html);
<div dangerouslySetInnerHTML={{ __html: clean }} />
3. URL Scheme Check
const safe = ['https:', 'mailto:'].includes(new URL(url).protocol);
4. Markdown Pipeline
const html = marked.parse(md);
const clean = DOMPurify.sanitize(html);
5. Trusted Type Pattern
type TrustedHtml = string & { __brand: 'TrustedHtml' };
6. External Link with Rel
<a href={safeHref(url)} rel="noopener noreferrer" target="_blank">
7. Ref with textContent
useEffect(() => { ref.current.textContent = text; }, [text]);
8. Inline Script Escape
json.replace(/</g, '\\u003c').replace(/>/g, '\\u003e');
9. DOMPurify URI Policy
DOMPurify.sanitize(html, { ALLOWED_URI_REGEXP: /^https?:/i });
10. CSP Header
script-src 'self' 'nonce-{random}'; object-src 'none';
Visual
Where React’s Escaping Stops
┌──────────────────────────────────────────────────────────────┐
│ JSX INTERPOLATION │
│ <div>{userInput}</div> │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ React escapes │ <script> renders as text │
│ │ to text node │ SAFE │
│ └─────────────────┘ │
│ │
│ dangerouslySetInnerHTML │
│ <div dangerouslySetInnerHTML={{__html: input}} /> │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Passes raw HTML │ <script> executes │
│ │ to innerHTML │ DANGEROUS │
│ └─────────────────┘ │
│ │
│ href={userUrl} │
│ <a href={userUrl}> │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Escapes HTML │ javascript:alert(1) survives │
│ │ entities only │ DANGEROUS on click │
│ └─────────────────┘ │
│ │
│ React protects one surface. The others are your job. │
│ │
└──────────────────────────────────────────────────────────────┘
Sanitization Flow
┌──────────────────────────────────────────────────────────────┐
│ UNTRUSTED HTML │
│ "<p>Hello</p><script>steal()</script>" │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ DOMPurify │ Parses into DOM tree │
│ │ .sanitize() │ Strips script, onerror, javascript: │
│ └────────┬────────┘ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ SAFE HTML │ "<p>Hello</p>" │
│ └────────┬────────┘ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ dangerouslySet │ Only sanitized output reaches sink │
│ │ InnerHTML │ │
│ └─────────────────┘ │
│ │
│ Sanitize at render time, every time. │
│ Write-time sanitization rots. │
│ │
└──────────────────────────────────────────────────────────────┘
URL Scheme Attack
┌──────────────────────────────────────────────────────────────┐
│ <a href={userUrl}>Click</a> │
│ │
│ userUrl = "javascript:alert(document.cookie)" │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ React escapes │ No HTML entities to escape │
│ │ entities │ javascript: passes through │
│ └────────┬────────┘ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ href="javascript│ Browser executes on click │
│ │ :alert(...)" │ XSS │
│ └─────────────────┘ │
│ │
│ FIX: Validate protocol before rendering │
│ new URL(url).protocol in ['https:', 'mailto:'] │
│ │
└──────────────────────────────────────────────────────────────┘
Defense in Depth
┌──────────────────────────────────────────────────────────────┐
│ LAYER 1: React JSX escaping │
│ └── Blocks text interpolation XSS │
│ │
│ LAYER 2: DOMPurify sanitization │
│ └── Blocks raw HTML XSS │
│ │
│ LAYER 3: URL scheme allowlist │
│ └── Blocks javascript: links │
│ │
│ LAYER 4: Content Security Policy │
│ └── Blocks inline scripts even if sanitizer misses │
│ │
│ LAYER 5: TypeScript branded types │
│ └── Prevents unsanitized HTML from reaching sink │
│ │
│ No single layer is sufficient. │
│ Together they make exploitation difficult. │
│ │
└──────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| JSX escaping | Text interpolation is safe by default |
| Raw HTML sink | dangerouslySetInnerHTML bypasses escaping |
| Sanitizer | DOMPurify at render time |
| URL sink | href/src need scheme validation |
| Scheme allowlist | https:, http:, mailto: |
| Markdown | Convert then sanitize, not trust the library |
| Imperative sink | Avoid innerHTML on refs; use textContent |
| SSR serialization | Escape <, >, & in inline scripts |
| CSP | Second layer; blocks inline execution |
| Type safety | Branded TrustedHtml type enforces sanitization |
Key takeaways:
- React escapes text, not HTML or URLs. The default protection covers
{value}in JSX. It does not coverdangerouslySetInnerHTML,href,src, or imperative DOM writes. Those four surfaces are where React XSS lives . dangerouslySetInnerHTMLrequires DOMPurify. The prop maps toinnerHTMLand executes whatever it receives. Sanitize at render time with a restrictive allowlist, not at write time. Markdown libraries do not sanitize their output; the conversion step must be followed by a sanitization step .- URL attributes need scheme validation. React treats
hrefas an opaque string.javascript:alert(1)passes through unmodified. Validate the protocol before rendering and reject anything outside the allowlist . - A single sanitizer module prevents policy drift. Two call sites to DOMPurify will eventually diverge, and the second one will lose the restrictive configuration. Wrap the sanitizer in a module and make every component use it .
- CSP is the layer that does not depend on developer discipline. A policy that blocks inline scripts and disallows
unsafe-inlinestops an injectedonerrorhandler from executing even if the sanitizer misses it. Deploy in report-only mode first, then enforce .
Remember: React’s XSS protection is a default, not a guarantee. It works exactly as designed: it escapes text interpolation and does nothing else. The vulnerabilities that appear in React applications appear at the edges of that design—the raw HTML prop that exists for CMS content, the URL attribute that exists for user-supplied links, the DOM write that exists for imperative integration. Each edge requires a specific mitigation: sanitization for HTML, scheme validation for URLs, textContent for imperative writes. The framework cannot enforce those mitigations because they depend on context the framework does not have. The developer has that context, and the responsibility that comes with it.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!