React Security and Best Practices
1. XSS Prevention and Input Sanitization
| Attack Vector | Risk | Prevention | Example |
|---|---|---|---|
| User Input in JSX | XSS via script injection | React automatically escapes JSX expressions | {userInput} is safe - React escapes HTML |
| URL Parameters | JavaScript execution via href | Validate and sanitize URLs, block javascript: protocol | Use allowlist for protocols (http:, https:, mailto:) |
| HTML Attributes | Event handler injection | Never use user input directly in event handlers | Avoid: onClick={eval(userInput)} |
| innerHTML/outerHTML | Direct HTML injection | Use React rendering, avoid dangerouslySetInnerHTML | Sanitize with DOMPurify if HTML needed |
| CSS Injection | Style-based XSS | Validate style values, use CSS-in-JS safely | Avoid: style={{'{{'}}background: userInput}} |
| SVG Content | Script tags in SVG | Sanitize SVG content, validate sources | Use SVG as components, not raw strings |
Example: Safe rendering of user input
// ✅ SAFE - React automatically escapes
function UserComment({ comment }) {
return <div>{comment.text}</div>; // HTML entities escaped
}
// ✅ SAFE - Using textContent
function DisplayText({ text }) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (ref.current) {
ref.current.textContent = text; // Safe, no HTML parsing
}
}, [text]);
return <div ref={ref} />;
}
// ❌ UNSAFE - Direct href from user input
function UnsafeLink({ url }) {
return <a href={url}>Click</a>; // Can be javascript:alert('XSS')
}
// ✅ SAFE - Validate URL protocol
function SafeLink({ url }) {
const isValidUrl = (url: string) => {
try {
const parsed = new URL(url);
return ['http:', 'https:', 'mailto:'].includes(parsed.protocol);
} catch {
return false;
}
};
if (!isValidUrl(url)) {
return <span>Invalid URL</span>;
}
return <a href={url} rel="noopener noreferrer">Click</a>;
}
Example: Input sanitization library
// Install DOMPurify for HTML sanitization
npm install dompurify
npm install --save-dev @types/dompurify
import DOMPurify from 'dompurify';
// Sanitize HTML content
function SanitizedHtml({ html }: { html: string }) {
const sanitized = DOMPurify.sanitize(html, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'],
ALLOWED_ATTR: ['href', 'title', 'target'],
ALLOW_DATA_ATTR: false,
});
return <div dangerouslySetInnerHTML={{ __html: sanitized }} />;
}
// Sanitize user input before storing
function CommentForm() {
const [comment, setComment] = useState('');
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
// Sanitize before sending to API
const sanitized = DOMPurify.sanitize(comment, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong'],
ALLOWED_ATTR: [],
});
await saveComment(sanitized);
};
return (
<form onSubmit={handleSubmit}>
<textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
maxLength={500}
/>
<button type="submit">Post Comment</button>
</form>
);
}
2. dangerouslySetInnerHTML Safe Usage
| Risk | Mitigation | Best Practice | Example |
|---|---|---|---|
| XSS Attacks | Always sanitize HTML before rendering | Use DOMPurify or similar library | Sanitize all user-generated content |
| Script Injection | Remove script tags and event handlers | Configure strict sanitization rules | Block <script>, onclick, onerror, etc. |
| Style Attacks | Limit allowed CSS properties | Whitelist safe CSS properties only | Avoid expression(), url(), import |
| Data Exfiltration | Block external resource loading | CSP headers, sanitize img/link src | Restrict to same-origin or trusted domains |
| iframe Injection | Block iframe tags unless necessary | Use sandbox attribute if needed | Restrict iframe capabilities |
| Form Hijacking | Block form tags in user content | Sanitize or block form elements | Prevent form submission hijacking |
Example: Safe dangerouslySetInnerHTML usage
import DOMPurify from 'dompurify';
// ❌ NEVER DO THIS - Unsafe!
function UnsafeHtml({ html }) {
return <div dangerouslySetInnerHTML={{ __html: html }} />;
}
// ✅ SAFE - Sanitized HTML
function SafeHtml({ html }: { html: string }) {
const sanitized = useMemo(() => {
return DOMPurify.sanitize(html, {
ALLOWED_TAGS: [
'h1', 'h2', 'h3', 'p', 'br', 'strong', 'em', 'u',
'ul', 'ol', 'li', 'a', 'blockquote', 'code', 'pre'
],
ALLOWED_ATTR: {
'a': ['href', 'title', 'target', 'rel'],
'img': ['src', 'alt', 'title', 'width', 'height']
},
ALLOW_DATA_ATTR: false,
FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'form'],
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover']
});
}, [html]);
return <div dangerouslySetInnerHTML={{ __html: sanitized }} />;
}
// ✅ BETTER - Use React components instead
function MarkdownRenderer({ markdown }: { markdown: string }) {
// Use a library like react-markdown
return <ReactMarkdown>{markdown}</ReactMarkdown>;
}
// Safe HTML with hooks
function useSafeHtml(html: string) {
return useMemo(() => {
if (!html) return '';
return DOMPurify.sanitize(html, {
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'a'],
ALLOWED_ATTR: ['href'],
KEEP_CONTENT: true,
});
}, [html]);
}
function RichTextDisplay({ content }: { content: string }) {
const safeHtml = useSafeHtml(content);
if (!content) {
return <p>No content available</p>;
}
return (
<div
className="rich-text-content"
dangerouslySetInnerHTML={{ __html: safeHtml }}
/>
);
}
Warning: Never use dangerouslySetInnerHTML with unsanitized user input. Always validate HTML
source, sanitize with DOMPurify, use strict whitelist of tags/attributes, implement Content Security Policy,
prefer React components over raw HTML, audit third-party HTML carefully.
3. Props Validation and Runtime Type Checking
| Approach | Tool | Features | Use Case |
|---|---|---|---|
| PropTypes | prop-types package |
Runtime validation, custom validators, development warnings | JavaScript projects, simple validation |
| TypeScript | Built-in type system | Compile-time checking, inference, strict mode | Type-safe React apps, preferred approach |
| Zod Runtime | zod schemas |
Runtime + compile-time, parsing, transformation | API responses, form validation, external data |
| io-ts | TypeScript runtime validation | Type guards, decoders, reporters | Complex validation, FP patterns |
| Yup | Schema validation | Object schemas, async validation, transforms | Form validation, data validation |
| Custom Validation | Manual checks | Full control, specific business logic | Complex validation requirements |
Example: PropTypes validation
import PropTypes from 'prop-types';
function UserCard({ user, onEdit, isActive }) {
return (
<div className={isActive ? 'active' : ''}>
<h3>{user.name}</h3>
<p>{user.email}</p>
<button onClick={onEdit}>Edit</button>
</div>
);
}
UserCard.propTypes = {
user: PropTypes.shape({
id: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
email: PropTypes.string.isRequired,
avatar: PropTypes.string,
}).isRequired,
onEdit: PropTypes.func.isRequired,
isActive: PropTypes.bool,
};
UserCard.defaultProps = {
isActive: false,
};
// Custom validators
function EmailValidator(props, propName, componentName) {
const email = props[propName];
if (email && !/^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$/.test(email)) {
return new Error(
`Invalid prop \`${propName}\` supplied to \`${componentName}\`. ` +
`Expected a valid email address.`
);
}
}
function ContactForm({ email }) {
return <input type="email" value={email} />;
}
ContactForm.propTypes = {
email: EmailValidator
};
Example: TypeScript with runtime validation
import { z } from 'zod';
// Define schema
const UserSchema = z.object({
id: z.number().positive(),
name: z.string().min(1).max(100),
email: z.string().email(),
age: z.number().min(0).max(150).optional(),
role: z.enum(['admin', 'user', 'guest']),
});
type User = z.infer<typeof UserSchema>;
// Component with TypeScript
interface UserCardProps {
user: User;
onEdit: (user: User) => void;
isActive?: boolean;
}
function UserCard({ user, onEdit, isActive = false }: UserCardProps) {
return (
<div className={isActive ? 'active' : ''}>
<h3>{user.name}</h3>
<p>{user.email}</p>
<button onClick={() => onEdit(user)}>Edit</button>
</div>
);
}
// Validate API response
async function fetchUser(id: number): Promise<User> {
const response = await fetch(`/api/users/${id}`);
const data = await response.json();
// Runtime validation
try {
return UserSchema.parse(data);
} catch (error) {
if (error instanceof z.ZodError) {
console.error('Invalid user data:', error.errors);
throw new Error('Invalid user data from API');
}
throw error;
}
}
// Safe parse without throwing
async function fetchUserSafe(id: number) {
const response = await fetch(`/api/users/${id}`);
const data = await response.json();
const result = UserSchema.safeParse(data);
if (!result.success) {
return { error: result.error, data: null };
}
return { error: null, data: result.data };
}
4. Authentication and Authorization Patterns
| Pattern | Implementation | Security Considerations | Use Case |
|---|---|---|---|
| JWT Tokens | Store in memory or httpOnly cookies | Never store in localStorage, use short expiry, refresh tokens | Stateless authentication, API access |
| Session Cookies | Server-side sessions, httpOnly secure cookies | CSRF protection, SameSite attribute, secure flag | Traditional web apps, server-side auth |
| OAuth 2.0 / OIDC | Third-party auth providers (Google, Auth0) | PKCE flow for SPAs, validate state parameter | Social login, enterprise SSO |
| Protected Routes | Conditional rendering, redirect logic | Server-side validation, check permissions | Role-based access control |
| API Security | Authorization headers, CORS configuration | Validate on server, don't trust client | API endpoint protection |
| MFA/2FA | Time-based OTP, SMS, authenticator apps | Backup codes, rate limiting, secure storage | Enhanced security for sensitive ops |
Example: JWT authentication with refresh tokens
// AuthContext.tsx
interface AuthContextType {
user: User | null;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
isAuthenticated: boolean;
}
const AuthContext = createContext<AuthContextType | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [accessToken, setAccessToken] = useState<string | null>(null);
// Store access token in memory only
const login = async (email: string, password: string) => {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include', // Include httpOnly refresh token cookie
body: JSON.stringify({ email, password }),
});
if (!response.ok) throw new Error('Login failed');
const { user, accessToken } = await response.json();
setUser(user);
setAccessToken(accessToken); // Store in memory
};
const logout = async () => {
await fetch('/api/auth/logout', {
method: 'POST',
credentials: 'include',
});
setUser(null);
setAccessToken(null);
};
// Refresh access token
useEffect(() => {
const refreshToken = async () => {
try {
const response = await fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'include', // Send httpOnly cookie
});
if (response.ok) {
const { user, accessToken } = await response.json();
setUser(user);
setAccessToken(accessToken);
}
} catch (error) {
console.error('Token refresh failed:', error);
}
};
refreshToken();
// Refresh before expiry (every 14 minutes for 15-minute tokens)
const interval = setInterval(refreshToken, 14 * 60 * 1000);
return () => clearInterval(interval);
}, []);
return (
<AuthContext.Provider value={{ user, login, logout, isAuthenticated: !!user }}>
{children}
</AuthContext.Provider>
);
}
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) throw new Error('useAuth must be used within AuthProvider');
return context;
};
Example: Protected routes and role-based access
// ProtectedRoute.tsx
interface ProtectedRouteProps {
children: ReactNode;
requiredRole?: string[];
redirectTo?: string;
}
function ProtectedRoute({
children,
requiredRole,
redirectTo = '/login'
}: ProtectedRouteProps) {
const { user, isAuthenticated } = useAuth();
const location = useLocation();
if (!isAuthenticated) {
// Redirect to login, preserve intended destination
return <Navigate to={redirectTo} state={{ from: location }} replace />;
}
// Check role-based access
if (requiredRole && !requiredRole.includes(user.role)) {
return <Navigate to="/unauthorized" replace />;
}
return <>{children}</>;
}
// Usage in routes
function App() {
return (
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/unauthorized" element={<Unauthorized />} />
<Route
path="/dashboard"
element={
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
}
/>
<Route
path="/admin"
element={
<ProtectedRoute requiredRole={['admin']}>
<AdminPanel />
</ProtectedRoute>
}
/>
</Routes>
);
}
// Secure API requests
function useSecureApi() {
const { accessToken } = useAuth();
const secureRequest = async (url: string, options: RequestInit = {}) => {
const response = await fetch(url, {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
credentials: 'include',
});
if (response.status === 401) {
// Token expired, trigger logout
throw new Error('Unauthorized');
}
return response;
};
return { secureRequest };
}
5. Secure State Management Practices
| Risk | Secure Practice | Implementation | Example |
|---|---|---|---|
| Sensitive Data in State | Never store passwords, credit cards, SSN in state | Use secure backend storage, tokenization | Store tokens server-side, use references only |
| localStorage Risks | Avoid storing auth tokens in localStorage | Use httpOnly cookies or memory storage | XSS can access localStorage but not httpOnly cookies |
| State Persistence | Encrypt sensitive persisted state | Use encryption libraries, secure key management | Encrypt before saving to storage |
| Redux DevTools | Disable in production or sanitize actions | Conditionally enable, filter sensitive data | Redact passwords, tokens from action logs |
| State Leakage | Clear sensitive state on logout | Reset stores, clear memory | Unmount components, clear caches |
| Cross-Tab State | Validate state across browser tabs | Use BroadcastChannel for sync, validate auth | Logout all tabs when one logs out |
Example: Secure state management
// ❌ NEVER DO THIS - Insecure!
localStorage.setItem('authToken', token);
localStorage.setItem('password', password);
localStorage.setItem('creditCard', cardNumber);
// ✅ SECURE - In-memory only, httpOnly cookies for refresh
const AuthProvider = ({ children }) => {
// Access token in memory only
const [accessToken, setAccessToken] = useState<string | null>(null);
// Refresh token in httpOnly cookie (set by server)
// Cookie: refreshToken=xxx; HttpOnly; Secure; SameSite=Strict
return (
<AuthContext.Provider value={{ accessToken }}>
{children}
</AuthContext.Provider>
);
};
// Secure state persistence
import CryptoJS from 'crypto-js';
function useSecureStorage(key: string) {
const encryptionKey = process.env.REACT_APP_ENCRYPTION_KEY;
const setSecureItem = (value: any) => {
const encrypted = CryptoJS.AES.encrypt(
JSON.stringify(value),
encryptionKey
).toString();
localStorage.setItem(key, encrypted);
};
const getSecureItem = () => {
const encrypted = localStorage.getItem(key);
if (!encrypted) return null;
try {
const decrypted = CryptoJS.AES.decrypt(encrypted, encryptionKey);
return JSON.parse(decrypted.toString(CryptoJS.enc.Utf8));
} catch {
return null;
}
};
const removeSecureItem = () => {
localStorage.removeItem(key);
};
return { setSecureItem, getSecureItem, removeSecureItem };
}
// Redux DevTools - sanitize sensitive data
const store = configureStore({
reducer: rootReducer,
devTools: process.env.NODE_ENV !== 'production' && {
actionSanitizer: (action) => ({
...action,
payload: action.type.includes('PASSWORD')
? '***REDACTED***'
: action.payload,
}),
stateSanitizer: (state) => ({
...state,
auth: {
...state.auth,
password: '***REDACTED***',
token: '***REDACTED***',
},
}),
},
});
// Clear state on logout
function useSecureLogout() {
const dispatch = useDispatch();
const queryClient = useQueryClient();
const logout = useCallback(async () => {
// Clear Redux state
dispatch({ type: 'RESET_STATE' });
// Clear React Query cache
queryClient.clear();
// Clear any local storage (except user preferences)
const preferences = localStorage.getItem('userPreferences');
localStorage.clear();
if (preferences) {
localStorage.setItem('userPreferences', preferences);
}
// Call logout API
await fetch('/api/auth/logout', {
method: 'POST',
credentials: 'include',
});
// Redirect to login
window.location.href = '/login';
}, [dispatch, queryClient]);
return logout;
}
6. Content Security Policy with React Apps
| CSP Directive | Purpose | React Configuration | Example Value |
|---|---|---|---|
| default-src | Fallback for other directives | Set restrictive default, override as needed | 'self' |
| script-src | Control script execution | Use nonce or hash for inline scripts | 'self' 'nonce-xyz123' |
| style-src | Control stylesheet loading | Use nonce for CSS-in-JS libraries | 'self' 'unsafe-inline' (use nonce instead) |
| img-src | Control image sources | Whitelist CDNs and data URIs if needed | 'self' data: https://cdn.example.com |
| connect-src | Control fetch/XHR destinations | Whitelist API endpoints | 'self' https://api.example.com |
| frame-ancestors | Prevent clickjacking | Control iframe embedding | 'none' or 'self' |
| upgrade-insecure-requests | Auto upgrade HTTP to HTTPS | Enable for production | No value needed (directive only) |
Example: CSP header configuration
// Next.js - next.config.js
const securityHeaders = [
{
key: 'Content-Security-Policy',
value: [
"default-src 'self'",
"script-src 'self' 'unsafe-eval' 'unsafe-inline'", // Adjust for production
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https:",
"font-src 'self' data:",
"connect-src 'self' https://api.example.com",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
"upgrade-insecure-requests",
].join('; '),
},
{
key: 'X-Frame-Options',
value: 'DENY',
},
{
key: 'X-Content-Type-Options',
value: 'nosniff',
},
{
key: 'Referrer-Policy',
value: 'strict-origin-when-cross-origin',
},
{
key: 'Permissions-Policy',
value: 'camera=(), microphone=(), geolocation=()',
},
];
module.exports = {
async headers() {
return [
{
source: '/:path*',
headers: securityHeaders,
},
];
},
};
// Express.js backend
import helmet from 'helmet';
app.use(
helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'"], // Use nonce in production
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "https:"],
connectSrc: ["'self'", "https://api.example.com"],
fontSrc: ["'self'", "data:"],
objectSrc: ["'none'"],
mediaSrc: ["'self'"],
frameSrc: ["'none'"],
frameAncestors: ["'none'"],
baseUri: ["'self'"],
formAction: ["'self'"],
upgradeInsecureRequests: [],
},
})
);
Example: Using CSP nonces with React
// Server-side (Express with React SSR)
import crypto from 'crypto';
import { renderToString } from 'react-dom/server';
app.get('*', (req, res) => {
// Generate nonce for this request
const nonce = crypto.randomBytes(16).toString('base64');
// Set CSP header with nonce
res.setHeader(
'Content-Security-Policy',
`script-src 'self' 'nonce-${nonce}'; style-src 'self' 'nonce-${nonce}'`
);
// Render app with nonce
const html = renderToString(<App nonce={nonce} />);
res.send(`
<!DOCTYPE html>
<html>
<head>
<style nonce="${nonce}">
/* Critical CSS */
</style>
</head>
<body>
<div id="root">${html}</div>
<script nonce="${nonce}" src="/static/bundle.js"></script>
</body>
</html>
`);
});
// React component using nonce
function App({ nonce }) {
return (
<HelmetProvider>
<Helmet>
<script nonce={nonce}>
{`window.__INITIAL_STATE__ = ${JSON.stringify(initialState)};`}
</script>
</Helmet>
<YourApp />
</HelmetProvider>
);
}
// Strict CSP for production
const productionCSP = {
defaultSrc: ["'none'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'"],
imgSrc: ["'self'", "data:", "https://trusted-cdn.com"],
fontSrc: ["'self'"],
connectSrc: ["'self'", "https://api.example.com"],
frameSrc: ["'none'"],
objectSrc: ["'none'"],
baseUri: ["'self'"],
formAction: ["'self'"],
frameAncestors: ["'none'"],
upgradeInsecureRequests: [],
};
CSP Best Practices: Start with strict policy, test thoroughly before production, avoid
'unsafe-inline' and 'unsafe-eval', use nonces or hashes for inline scripts, whitelist specific domains not
wildcards, implement CSP reporting endpoint, monitor violations, update policy as app evolves, test in
report-only mode first.