Next.js Security: The Complete Hardening Guide for 2026
Next.js Security Has Fundamentally Changed
Next.js 15 and 16 introduced Server Components, Server Actions, and the App Router — blurring the line between client and server code. This creates entirely new attack vectors that didn't exist in Pages Router applications.
| Feature | Security Impact | Risk Level |
|---|---|---|
| Server Components | Server code accidentally exposed to client | High |
| Server Actions | Direct function calls bypass API middleware | Critical |
| Route Handlers | Missing auth checks on API routes | High |
| Middleware | Auth bypass via matcher misconfiguration | High |
| Environment Variables | Client/server variable confusion | Medium |
Server Actions: The Biggest Attack Surface
Server Actions let you call server-side functions directly from the client — without going through an API route. This means all your API middleware (auth, rate limiting, CORS) gets bypassed.
❌ Vulnerable Server Action
// app/actions.ts
'use server';
export async function deleteUser(userId: string) {
// NO AUTH CHECK — anyone can call this!
await db.user.delete({ where: { id: userId } });
return { success: true };
}
An attacker can call this action directly via POST request:
curl -X POST https://yourapp.com/actions \
-H "Content-Type: application/x-www-form-urlencoded" \
-H "Next-Action: <action-id>" \
-d "userId=admin-user-id"
✅ Secure Server Action
'use server';
import { auth } from '@/lib/auth';
import { z } from 'zod';
const DeleteUserSchema = z.object({
userId: z.string().uuid(),
});
export async function deleteUser(rawData: unknown) {
// 1. Authentication
const session = await auth();
if (!session?.user) throw new Error('Unauthorized');
// 2. Input validation
const { userId } = DeleteUserSchema.parse(rawData);
// 3. Authorization — only admins or self-delete
if (session.user.role !== 'admin' && session.user.id !== userId) {
throw new Error('Forbidden');
}
// 4. Rate limiting
await rateLimit(session.user.id, 'deleteUser', { max: 5, window: '1h' });
await db.user.delete({ where: { id: userId } });
revalidatePath('/users');
return { success: true };
}
Every Server Action needs: Authentication → Input Validation → Authorization → Rate Limiting
Environment Variable Leaks
Next.js has a dangerous naming convention:
# .env.local
# ✅ Server-only (safe)
DATABASE_URL=postgres://user:pass@host/db
SECRET_KEY=my-secret-key
# ❌ EXPOSED TO CLIENT BROWSER
NEXT_PUBLIC_DATABASE_URL=postgres://user:pass@host/db # NEVER DO THIS
NEXT_PUBLIC_SECRET_KEY=my-secret-key # NEVER DO THIS
# ✅ Safe public variables
NEXT_PUBLIC_APP_URL=https://myapp.com
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_...
Rule: Only prefix with NEXT_PUBLIC_ if the value can be seen by anyone.
Checking for Leaked Variables
# Search your codebase for dangerous patterns
grep -r "NEXT_PUBLIC_.*SECRET\|NEXT_PUBLIC_.*PASSWORD\|NEXT_PUBLIC_.*DATABASE" .
Middleware Authentication Bypass
Next.js middleware runs on the Edge Runtime and is powerful for auth — but matcher patterns are tricky.
❌ Bypassable Middleware
// middleware.ts
export const config = {
matcher: ['/dashboard', '/api/:path*'],
};
export function middleware(request: NextRequest) {
const token = request.cookies.get('session');
if (!token) return NextResponse.redirect(new URL('/login', request.url));
}
// ❌ These routes are NOT protected:
// /Dashboard (case-sensitive bypass)
// /dashboard/ (trailing slash)
// /api (exact match might not catch this)
✅ Secure Middleware
export const config = {
matcher: [
// Match all routes except static files and public assets
'/((?!_next/static|_next/image|favicon.ico|public/).*)',
],
};
const publicPaths = ['/login', '/register', '/forgot-password', '/', '/about'];
export function middleware(request: NextRequest) {
const path = request.nextUrl.pathname.toLowerCase();
// Skip public paths
if (publicPaths.some(p => path === p || path === p + '/')) {
return NextResponse.next();
}
const token = request.cookies.get('session');
if (!token) {
return NextResponse.redirect(new URL('/login', request.url));
}
// Verify token is valid (basic check — full validation in route)
try {
const payload = verifyToken(token.value);
const headers = new Headers(request.headers);
headers.set('x-user-id', payload.userId);
return NextResponse.next({ headers });
} catch {
return NextResponse.redirect(new URL('/login', request.url));
}
}
Security Headers Configuration
// next.config.js
const securityHeaders = [
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'X-XSS-Protection', value: '1; mode=block' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
{
key: 'Content-Security-Policy',
value: [
"default-src 'self'",
"script-src 'self' 'unsafe-eval' 'unsafe-inline'", // Tighten for production
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob: https:",
"font-src 'self'",
"connect-src 'self' https://api.yourapp.com",
"frame-ancestors 'none'",
].join('; '),
},
{
key: 'Strict-Transport-Security',
value: 'max-age=63072000; includeSubDomains; preload',
},
];
module.exports = {
async headers() {
return [{ source: '/(.*)', headers: securityHeaders }];
},
};
Route Handler Security Checklist
// app/api/users/[id]/route.ts
import { auth } from '@/lib/auth';
import { z } from 'zod';
import { NextResponse } from 'next/server';
import { rateLimit } from '@/lib/rate-limit';
const ParamsSchema = z.object({ id: z.string().uuid() });
export async function GET(
request: Request,
{ params }: { params: { id: string } }
) {
// ✅ Rate limiting
const ip = request.headers.get('x-forwarded-for') ?? 'unknown';
const { success } = await rateLimit(ip, 'api', { max: 100, window: '1m' });
if (!success) return NextResponse.json({ error: 'Too many requests' }, { status: 429 });
// ✅ Authentication
const session = await auth();
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
// ✅ Input validation
const parsed = ParamsSchema.safeParse(params);
if (!parsed.success) return NextResponse.json({ error: 'Invalid ID' }, { status: 400 });
// ✅ Authorization
if (session.user.role !== 'admin' && session.user.id !== parsed.data.id) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const user = await db.user.findUnique({ where: { id: parsed.data.id } });
if (!user) return NextResponse.json({ error: 'Not found' }, { status: 404 });
// ✅ Don't return sensitive fields
const { passwordHash, ...safeUser } = user;
return NextResponse.json(safeUser);
}
Quick Reference: Next.js Security Checklist
- Every Server Action has auth + input validation + authorization
- No secrets in
NEXT_PUBLIC_environment variables - Middleware covers all protected routes (test edge cases)
- Security headers configured in next.config.js
- CSRF protection on state-changing operations
- Rate limiting on all API routes and Server Actions
-
dangerouslySetInnerHTMLonly used with DOMPurify sanitization - File uploads validated (type, size, content) server-side
- Error responses don't leak stack traces or internal details
- Dependencies audited (
npm audit, Snyk, or ShieldX)
Advertisement
Free Security Tools
Try our tools now
Expert Services
Get professional help
OWASP Top 10
Learn the top risks
Related Articles
Threat Modeling for Developers: STRIDE, PASTA & DREAD with Practical Examples
Threat modeling is the most cost-effective security activity — finding design flaws before writing code. This guide covers STRIDE, PASTA, and DREAD methodologies with real-world examples for web, API, and cloud applications.
Building a Security Champions Program: Scaling Security Across Dev Teams
Security teams can't review every line of code. Security Champions embed security expertise in every development team. This guide covers program design, champion selection, training, metrics, and sustaining engagement.
The Ultimate Secure Code Review Checklist for 2025
A comprehensive, language-agnostic checklist for secure code reviews. Use this as your team's standard for catching vulnerabilities before they reach production.