TL;DR
The #1 React security best practice is avoiding dangerouslySetInnerHTML and validating all user input. React escapes content by default, but security still requires attention. Never store secrets in client code, and use HttpOnly cookies for auth tokens. Following these practices prevents 76% of frontend security vulnerabilities. Total time: approximately 35 minutes to implement all 8 best practices.
"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 automatically escapes values rendered in JSX, preventing most XSS attacks:
// 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
However, there are ways to bypass this protection. Avoid them unless absolutely necessary.
Best Practice 1: Avoid dangerouslySetInnerHTML 3 min
The dangerouslySetInnerHTML prop bypasses React's XSS protection:
// 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
Never trust user input. Validate on both client and server:
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
URLs from user input can be used for attacks:
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
Handle authentication tokens securely:
// 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
If your backend uses cookies for authentication, implement 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
React apps run in the browser. All environment variables are exposed:
// 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
Third-party packages can introduce vulnerabilities:
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
Handle API errors and data safely:
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.
Verify Your React Security
Scan your React project for security issues and vulnerabilities.
Start Free Scan