XSS in React: How It Happens
React is often praised for being "secure by default" against XSS attacks. And it's true — React's JSX automatically escapes values before rendering them:
// Safe — React escapes the value
const userInput = '<script>alert("xss")</script>';
return <div>{userInput}</div>;
// Renders as text: <script>alert("xss")</script>But "secure by default" doesn't mean "impossible to break." Here are the ways XSS still happens in React applications:
### 1. dangerouslySetInnerHTML The most obvious vector. React named it "dangerously" for a reason:
// VULNERABLE — unsanitized user input rendered as HTML
return <div dangerouslySetInnerHTML={{ __html: userComment }} />;### 2. href and src Attributes React does not sanitize URL attributes:
// VULNERABLE — javascript: protocol executes code
const userUrl = 'javascript:alert(document.cookie)';
return <a href={userUrl}>Click here</a>;3. eval() and Dynamic Code
// VULNERABLE — executing user-controlled strings
eval(userInput);
new Function(userInput)();
setTimeout(userInput, 0); // string form### 4. Server-Side Rendering (SSR) When rendering React on the server, the initial HTML is sent as a string. If user input is included in that string without escaping, XSS happens before React even hydrates.
dangerouslySetInnerHTML — When and How to Use It Safely
Sometimes you need to render HTML — blog posts from a CMS, rich text editor output, or formatted content from an API. Here's how to do it safely:
Always Sanitize with DOMPurify
import DOMPurify from 'dompurify';
function SafeHtml({ html }) {
const sanitized = DOMPurify.sanitize(html, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br', 'ul', 'ol', 'li', 'h2', 'h3', 'code', 'pre'],
ALLOWED_ATTR: ['href', 'target', 'rel'],
});
return <div dangerouslySetInnerHTML={{ __html: sanitized }} />;
}### Key Rules 1. Never skip sanitization — even for "trusted" sources. APIs get compromised, databases get injected. 2. Whitelist, don't blacklist — Specify allowed tags and attributes explicitly. 3. Use a mature library — DOMPurify is battle-tested. Don't write your own sanitizer. 4. Sanitize on render, not on storage — Sanitization rules change; always clean at the point of output.
Content Security Policy for React Apps
A Content Security Policy (CSP) is your second line of defense against XSS. Even if an attacker finds a way to inject a script, CSP can prevent it from executing.
### The Challenge with React React and many CSS-in-JS libraries use inline styles and sometimes inline scripts, which conflict with strict CSP. Here's how to handle it:
Using Nonces (Recommended)
// next.config.js — Next.js CSP with nonce
const nextConfig = {
async headers() {
return [
{
source: '/(.*)',
headers: [
{
key: 'Content-Security-Policy',
value: `
default-src 'self';
script-src 'self' 'nonce-{NONCE}';
style-src 'self' 'unsafe-inline';
img-src 'self' data: https:;
font-src 'self';
connect-src 'self' https://api.example.com;
`.replace(/\n/g, ' ').trim(),
},
],
},
];
},
};### Report-Only Mode First
Always start with Content-Security-Policy-Report-Only to see what would break before enforcing:
Content-Security-Policy-Report-Only: default-src 'self'; report-uri /csp-reportMonitor the reports for a week, adjust your policy, then switch to enforcing mode.
Avoiding Injection in Props
### URL Validation
Always validate URLs before using them in href or src:
function SafeLink({ url, children }) {
const isValidUrl = (url) => {
try {
const parsed = new URL(url);
return ['http:', 'https:', 'mailto:'].includes(parsed.protocol);
} catch {
return false;
}
};
if (!isValidUrl(url)) {
return <span>{children}</span>;
}
return <a href={url} rel="noopener noreferrer">{children}</a>;
}### Prop Spreading Avoid spreading user-controlled objects into components:
// DANGEROUS — user controls which props are set
const userProps = getUserProps(); // { onError: 'alert(1)', style: '...' }
return <div {...userProps} />;
// SAFE — explicitly pick allowed props
const { title, className } = getUserProps();
return <div title={title} className={className} />;Secure Authentication Patterns
Token Storage
// BAD — accessible via XSS
localStorage.setItem('token', jwt);
// BETTER — httpOnly cookie (set by server)
// The token is never accessible to JavaScript
// Server sets: Set-Cookie: token=jwt; HttpOnly; Secure; SameSite=StrictProtected Routes
function ProtectedRoute({ children }) {
const { user, loading } = useAuth();
if (loading) return <Spinner />;
if (!user) return <Navigate to="/login" replace />;
return children;
}Session Timeout
function useSessionTimeout(timeoutMs = 30 * 60 * 1000) {
useEffect(() => {
let timer;
const resetTimer = () => {
clearTimeout(timer);
timer = setTimeout(() => {
logout();
navigate('/login?reason=timeout');
}, timeoutMs);
};
window.addEventListener('mousemove', resetTimer);
window.addEventListener('keypress', resetTimer);
resetTimer();
return () => {
clearTimeout(timer);
window.removeEventListener('mousemove', resetTimer);
window.removeEventListener('keypress', resetTimer);
};
}, [timeoutMs]);
}Protecting API Keys and Secrets
Never in Client Code
// WRONG — visible in browser source
const API_KEY = 'sk_live_abc123';
fetch(`https://api.stripe.com/v1/charges`, {
headers: { Authorization: `Bearer ${API_KEY}` }
});
// RIGHT — proxy through your backend
fetch('/api/create-charge', {
method: 'POST',
body: JSON.stringify({ amount: 1000 }),
});Environment Variables
# .env — NOT committed to git
NEXT_PUBLIC_API_URL=https://api.example.com # OK — public
STRIPE_SECRET_KEY=sk_live_xxx # Server-only
# .gitignore
.env
.env.local
.env.productionRule: Any variable prefixed with NEXT_PUBLIC_ or REACT_APP_ is bundled into client code and visible to everyone. Never put secrets in these.
Dependency Auditing (npm audit)
Your React app likely has hundreds of dependencies. Each one is a potential attack vector.
Regular Auditing
# Check for known vulnerabilities
npm audit
# Auto-fix where possible
npm audit fix
# See detailed report
npm audit --jsonAutomated in CI/CD
# GitHub Actions
- name: Security audit
run: npm audit --audit-level=high
# Fails the build if high or critical vulnerabilities exist### Lock File Integrity
Always commit your package-lock.json or yarn.lock. This ensures everyone installs the exact same versions and prevents supply chain attacks through version ranges.
### Dependency Review Before adding a new package: 1. Check download count and maintenance status on npm 2. Look for recent security advisories 3. Review the package's own dependencies 4. Consider if you can implement the functionality yourself
CORS in React Apps
CORS (Cross-Origin Resource Sharing) is configured on your backend, not in React. But understanding it prevents common mistakes:
// Backend (Express) — strict CORS configuration
const cors = require('cors');
app.use(cors({
origin: ['https://your-app.com'], // NOT '*' in production
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
}));Common mistake: Setting Access-Control-Allow-Origin: * to "fix" CORS errors. This allows any website to make requests to your API, which is a security risk if your API handles authenticated requests.
Input Validation
Validate all user input — both in React (for UX) and on the backend (for security):
function ContactForm() {
const validateEmail = (email) => {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
};
const sanitizeInput = (input) => {
return input.trim().slice(0, 1000); // Limit length
};
const handleSubmit = (e) => {
e.preventDefault();
const email = sanitizeInput(e.target.email.value);
const message = sanitizeInput(e.target.message.value);
if (!validateEmail(email)) {
setError('Invalid email');
return;
}
// Send to backend — backend validates again
};
}Client-side validation is for UX, not security. Always validate on the server.
Third-Party Libraries
Review What You Import
// Instead of importing all of lodash (71KB)
import _ from 'lodash';
// Import only what you need
import debounce from 'lodash/debounce';### Subresource Integrity (SRI) For CDN-loaded scripts, use SRI to ensure they haven't been tampered with:
<script
src="https://cdn.example.com/library.js"
integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8w"
crossorigin="anonymous"
></script>### Content Security Policy Limit which external sources your app can load resources from:
Content-Security-Policy: script-src 'self' https://cdn.trusted.com;Server-Side Rendering (SSR) Security
SSR introduces unique security considerations:
Serialization Attacks
// VULNERABLE — user data in serialized state
<script
dangerouslySetInnerHTML={{
__html: `window.__INITIAL_STATE__ = ${JSON.stringify(state)}`
}}
/>
// SAFE — use a serialization library that escapes HTML
import serialize from 'serialize-javascript';
<script
dangerouslySetInnerHTML={{
__html: `window.__INITIAL_STATE__ = ${serialize(state, { isJSON: true })}`
}}
/>### Environment Variable Leakage Server-side code has access to all environment variables. Make sure sensitive values don't get serialized into the client-side HTML:
// getServerSideProps — runs on server only
export async function getServerSideProps() {
const data = await fetch(process.env.INTERNAL_API_URL, {
headers: { Authorization: `Bearer ${process.env.API_SECRET}` }
});
// Only pass sanitized data to the client
return { props: { items: data.items } };
// NOT: return { props: { items: data.items, apiSecret: process.env.API_SECRET } }
}Build Your Security Scan into CI/CD
Security shouldn't be a manual step. Automate it:
# .github/workflows/security.yml
name: Security Checks
on: [push, pull_request]
jobs:
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: npm ci
- name: Dependency audit
run: npm audit --audit-level=high
- name: Lint for security issues
run: npx eslint . --plugin security
- name: Build
run: npm run build
- name: Check bundle for secrets
run: npx secretlint "build/**/*.js"### Post-Deployment Verification After deploying, run an automated security scan to verify your production configuration. Tools like ZeriFlow can check your deployed site's headers, TLS configuration, and security posture in under 60 seconds — integrate it into your deployment pipeline for continuous verification.
Security in React isn't a one-time checklist. It's a set of practices that should be embedded in your development workflow, from the first line of code to every production deployment.
