React Security Checklist: XSS, dangerouslySetInnerHTML, and Dependency Audits
Secure React applications by avoiding dangerouslySetInnerHTML pitfalls, using DOMPurify, implementing secure routing, preventing env var exposure, and running npm audit.
React Security Checklist: XSS, dangerouslySetInnerHTML, and Dependency Audits
React's component model and JSX auto-escaping make it inherently safer than raw DOM manipulation, but several React-specific patterns introduce vulnerabilities that developers regularly overlook. This checklist covers every major React security concern with concrete examples.
XSS and JSX Auto-Escaping
React escapes all values rendered through JSX expressions by default. This is your first line of defense:
// Safe — React escapes the string
const userInput = '<script>alert("xss")</script>';
return <div>{userInput}</div>;
// Renders as: <script>alert("xss")</script>
// Safe — JSX attributes are also escaped
return <img src={userProvidedUrl} alt={userProvidedAlt} />;
The only way to bypass this protection is dangerouslySetInnerHTML.
The dangerouslySetInnerHTML Problem
The name is a warning, not a suggestion. dangerouslySetInnerHTML disables React's XSS protection and injects raw HTML into the DOM:
// DANGEROUS — direct XSS if content contains <script> tags
function UserComment({ content }: { content: string }) {
return <div dangerouslySetInnerHTML={{ __html: content }} />;
}
If you must render HTML (rich text editors, markdown output, email previews), sanitize with DOMPurify before injecting:
npm install dompurify
npm install @types/dompurify # for TypeScript
import DOMPurify from 'dompurify';
const ALLOWED_TAGS = ['b', 'i', 'em', 'strong', 'a', 'p', 'br', 'ul', 'ol', 'li'];
const ALLOWED_ATTR = ['href', 'rel', 'target'];
function SafeHtml({ content }: { content: string }) {
const clean = DOMPurify.sanitize(content, {
ALLOWED_TAGS,
ALLOWED_ATTR,
FORCE_BODY: true,
});
return <div dangerouslySetInnerHTML={{ __html: clean }} />;
}
For server-side rendering (Next.js, Remix), use isomorphic-dompurify:
npm install isomorphic-dompurify
import DOMPurify from 'isomorphic-dompurify';
const clean = DOMPurify.sanitize(serverRenderedHtml);
URL Injection: The href and src Vulnerability
React does not sanitize URL attributes. javascript: URIs in href execute JavaScript:
// DANGEROUS — if userUrl is "javascript:alert(1)"
function UserLink({ href, label }: { href: string; label: string }) {
return <a href={href}>{label}</a>; // XSS
}
// Safe — validate URL scheme before rendering
function SafeLink({ href, label }: { href: string; label: string }) {
const safeHref = (() => {
try {
const url = new URL(href);
return ['http:', 'https:'].includes(url.protocol) ? href : '#';
} catch {
return '#';
}
})();
return (
<a href={safeHref} rel="noopener noreferrer">
{label}
</a>
);
}
Always add rel="noopener noreferrer" to links that open in a new tab — this prevents the opened page from accessing window.opener.
Environment Variable Exposure
Build tools like Create React App, Vite, and Next.js support environment variables in the client bundle. The danger: any variable prefixed with REACT_APP_ (CRA), VITE_ (Vite), or NEXT_PUBLIC_ (Next.js) is embedded in the JavaScript bundle and visible to everyone.
# .env — these are DANGEROUS if they contain secrets
REACT_APP_API_KEY=sk-1234abcd # Exposed in bundle
VITE_STRIPE_SECRET=sk_live_... # Exposed in bundle — NEVER DO THIS
NEXT_PUBLIC_DATABASE_PASSWORD=... # Exposed in bundle — NEVER DO THIS
# Safe — these are server-only (Next.js, SSR frameworks)
DATABASE_URL=mongodb://...
JWT_SECRET=supersecret
STRIPE_SECRET_KEY=sk_live_...
Audit your bundle for accidentally exposed secrets:
# Build and search the output for sensitive patterns
npm run build
grep -r "sk_live\|secret\|password\|private_key" dist/
Use @next/bundle-analyzer or vite-bundle-visualizer to inspect bundle contents.
Secure Routing and Protected Routes
Implement route guards that check authentication state before rendering protected content:
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/hooks/useAuth';
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { user, isLoading } = useAuth();
const router = useRouter();
useEffect(() => {
if (!isLoading && !user) {
router.replace('/login');
}
}, [user, isLoading, router]);
if (isLoading) return <LoadingSpinner />;
if (!user) return null;
return <>{children}</>;
}
Client-side route guards are a UX convenience, not a security control. The real enforcement must happen on the server — in middleware, server actions, or API handlers. Never rely solely on client-side checks.
The eval() and Function Constructor Risk
Avoid eval(), new Function(), and setTimeout(string) — they execute arbitrary code:
// DANGEROUS
eval(userInput);
new Function('return ' + userInput)();
setTimeout(userInput, 1000);
// Also dangerous in template literals passed to eval
const expression = `${userInput} + 2`;
eval(expression);
These patterns are rarely necessary. If you're evaluating formulas or expressions, use a safe math parser library instead of eval.
PostMessage Security
If your app communicates with iframes or other windows via postMessage, always validate the origin:
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
// ALWAYS validate origin
if (event.origin !== 'https://trusted-domain.com') {
return;
}
// ALWAYS validate message structure
if (typeof event.data !== 'object' || event.data.type !== 'UPDATE_PRICE') {
return;
}
handlePriceUpdate(event.data.payload);
};
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, []);
Dependency Auditing
React applications typically have hundreds of transitive dependencies. Vulnerabilities in any of them affect your app.
# Built-in npm audit
npm audit
npm audit --audit-level=high # Only fail on high/critical
# Fix automatically (patch-level only)
npm audit fix
# More comprehensive: Snyk
npx snyk test
npx snyk monitor
Add to CI:
# .github/workflows/security.yml
- name: Dependency audit
run: npm audit --audit-level=high
Check for abandoned or low-quality packages:
# npx depcheck — find unused dependencies
npx depcheck
# npm outdated — see what's behind
npm outdated
Content Security Policy
Add a CSP header to prevent XSS even if a vulnerability slips through. In Next.js:
// next.config.ts
const csp = `
default-src 'self';
script-src 'self' 'strict-dynamic' 'nonce-REPLACE_WITH_NONCE';
style-src 'self' 'unsafe-inline';
img-src 'self' data: https:;
connect-src 'self' https://api.yourdomain.com;
frame-ancestors 'none';
`.replace(/\n/g, '');
React-Specific Security Checklist
- No
dangerouslySetInnerHTMLwithout DOMPurify sanitization - URL attributes validated — block
javascript:URIs - No secrets in
NEXT_PUBLIC_,REACT_APP_, orVITE_prefixed vars - Client-side route guards exist, but server-side enforcement is the real control
- No
eval()ornew Function()on user input -
postMessagehandlers validate origin -
npm auditruns in CI with--audit-level=high - Dependencies reviewed for abandonment and known vulnerabilities
- CSP header configured
- All
<a target="_blank">links haverel="noopener noreferrer"