TL;DR
The #1 React security best practice is avoiding dangerouslySetInnerHTML and validating all user input. React escapes content by default, which handles the common case and none of the interesting ones. Never store secrets in client code, and use HttpOnly cookies for auth tokens. About 35 minutes to implement all 8.
"React protects you from XSS by default, but one dangerouslySetInnerHTML can undo it all. Trust React's escaping, distrust user input."
React's Built-in XSS Protection
React escapes values rendered in JSX, which is why most React apps never see a classic XSS bug:
// Safe: React escapes the content
function UserComment({ comment }) {
return <p>{comment}</p>;
}
// Even if comment contains:
// "<script>alert('XSS')</script>"
// React renders it as text, not HTML
There are three ways out of that protection, and an AI assistant will reach for the first one the moment you ask it to render formatted text. The rest of this post is mostly about those exits.
Best Practice 1: Avoid dangerouslySetInnerHTML 3 min
The prop is named the way it is on purpose. It turns off the escaping that was protecting you, for that one element:
// DANGEROUS: Renders HTML directly
function UnsafeComponent({ htmlContent }) {
return <div dangerouslySetInnerHTML={{ __html: htmlContent }} />;
}
If you must render HTML, sanitize it first:
import DOMPurify from 'dompurify';
function SaferHtmlComponent({ htmlContent }) {
const sanitizedHtml = DOMPurify.sanitize(htmlContent, {
ALLOWED_TAGS: ['p', 'b', 'i', 'em', 'strong', 'a'],
ALLOWED_ATTR: ['href'],
});
return <div dangerouslySetInnerHTML={{ __html: sanitizedHtml }} />;
}
Better alternative: Use a markdown library like react-markdown instead of rendering raw HTML. It provides structured, safe rendering of formatted content.
Best Practice 2: Validate All User Input 5 min
Client-side validation is a UX feature, not a security control: anyone can skip your form and POST directly to the API. Validate in both places, and treat the server copy as the real one:
import { z } from 'zod';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
const userSchema = z.object({
email: z.string().email('Invalid email'),
name: z.string()
.min(2, 'Name too short')
.max(100, 'Name too long')
.regex(/^[a-zA-Z\s]+$/, 'Name can only contain letters'),
website: z.string().url().optional().or(z.literal('')),
});
function ProfileForm() {
const { register, handleSubmit, formState: { errors } } = useForm({
resolver: zodResolver(userSchema),
});
const onSubmit = async (data) => {
// Data is validated - safe to send to API
await updateProfile(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email')} />
{errors.email && <span>{errors.email.message}</span>}
{/* ... */}
</form>
);
}
Best Practice 3: Secure URL Handling 3 min
A URL a user supplied is a string that decides where a click goes, and javascript: is a valid protocol. React won't escape its way out of that one:
function SafeLink({ url, children }) {
const isValidUrl = (urlString) => {
try {
const url = new URL(urlString);
// Only allow http and https protocols
return ['http:', 'https:'].includes(url.protocol);
} catch {
return false;
}
};
if (!isValidUrl(url)) {
return <span>{children}</span>; // Render as text if invalid
}
return (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
>
{children}
</a>
);
}
Watch out for: javascript: URLs can execute code. Never allow user-provided URLs in href without validation. The javascript: protocol bypasses React's XSS protection.
Best Practice 4: Secure Authentication State 5 min
This is the decision that most often gets made by accident, because localStorage.setItem is the first thing that works:
// WRONG: Storing token in localStorage (vulnerable to XSS)
localStorage.setItem('token', authToken);
// BETTER: Use HttpOnly cookies (set by server)
// The cookie is automatically sent with requests
// and cannot be accessed by JavaScript
// For API calls, let the browser handle cookies:
async function fetchUserData() {
const response = await fetch('/api/user', {
credentials: 'include', // Send cookies
});
return response.json();
}
// If you must use tokens in memory (SPA with separate API):
// Store in memory, not localStorage
const authStore = {
token: null,
setToken(token) { this.token = token; },
getToken() { return this.token; },
clearToken() { this.token = null; },
};
Best Practice 5: Protect Against CSRF 5 min
Cookies get attached to requests automatically, which is convenient for you and equally convenient for a form on someone else's site. If your backend authenticates with cookies, you need CSRF protection:
// Get CSRF token from meta tag or cookie
function getCsrfToken() {
return document.querySelector('meta[name="csrf-token"]')?.content;
}
// Include in API calls
async function secureApiCall(endpoint, data) {
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': getCsrfToken(),
},
credentials: 'include',
body: JSON.stringify(data),
});
return response.json();
}
Best Practice 6: Environment Variables 2 min
There's no such thing as a private environment variable in a React build. Anything prefixed VITE_ or REACT_APP_ gets compiled into the JavaScript you ship, and View Source is all it takes:
// In React (Create React App, Vite):
// All REACT_APP_ or VITE_ prefixed vars are bundled into the app
// SAFE: Public configuration
const apiUrl = import.meta.env.VITE_API_URL;
const publicKey = import.meta.env.VITE_STRIPE_PUBLIC_KEY;
// NEVER DO THIS: These would be exposed to users
// const apiSecret = import.meta.env.VITE_API_SECRET; // WRONG!
// const dbPassword = import.meta.env.VITE_DB_PASS; // WRONG!
// Keep secrets on your backend server, not in React
Best Practice 7: Secure Dependencies 5 min
Your dependency tree is code you shipped without reading. A typical React app pulls in several hundred packages transitively:
Dependency Security Checklist:
- Run npm audit regularly and fix vulnerabilities
- Use npm audit --fix for automatic patches
- Review packages before installing (check downloads, maintenance)
- Keep dependencies updated, especially security patches
- Use lock files (package-lock.json) for reproducible builds
- Consider tools like Snyk or Dependabot for monitoring
Best Practice 8: Secure API Calls 7 min
The trap here is the error path. A caught error stringified straight into the UI can hand a user your stack trace, your database column names, or an internal hostname:
async function fetchData(endpoint) {
try {
const response = await fetch(endpoint, {
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
});
if (response.status === 401) {
// Handle unauthorized - redirect to login
window.location.href = '/login';
return null;
}
if (!response.ok) {
// Log error but don't expose details to user
console.error('API error:', response.status);
throw new Error('Request failed');
}
return await response.json();
} catch (error) {
// Generic error message for users
// Log full error for debugging
console.error('Fetch error:', error);
throw new Error('Unable to load data. Please try again.');
}
}
Common React Security Mistakes
| Mistake | Risk | Prevention |
|---|---|---|
| Using dangerouslySetInnerHTML | XSS attacks | Sanitize with DOMPurify or avoid |
| Storing tokens in localStorage | Token theft via XSS | Use HttpOnly cookies |
| Secrets in env vars | Credential exposure | Keep secrets on backend only |
| Unsanitized URLs in href | XSS via javascript: URLs | Validate URL protocol |
| Missing input validation | Injection attacks | Validate with Zod or similar |
Official Resources: For the latest information, see React Documentation on Component Purity, React dangerouslySetInnerHTML Reference, and OWASP XSS Prevention Guide.
Does React prevent XSS automatically?
React escapes content rendered in JSX, preventing most XSS. However, dangerouslySetInnerHTML, javascript: URLs, and some edge cases can still allow XSS. Always validate user input and avoid rendering raw HTML.
Where should I store auth tokens in React?
Prefer HttpOnly cookies set by your server. If you must use tokens in a SPA with a separate API, store them in memory (React state/context) rather than localStorage. Tokens in localStorage are vulnerable to XSS.
Can I put API keys in my React app?
Only publishable/public keys that are designed for client-side use (like Stripe publishable keys). Secret keys must stay on your server. Everything in a React app is visible to users.
How do I handle authentication in React?
Use established libraries like NextAuth, Auth0, or Firebase Auth rather than building custom auth. If using JWTs, validate them on your server for every request, not just on the client.
Further Reading
Put these practices into action with our step-by-step guides.
Verify Your React Security
Scan your React project for security issues and vulnerabilities.