Next.js Security Best Practices: Headers, Auth, and API Routes
Secure your Next.js application from the ground up — covering security headers, API route protection, server actions, environment variable handling, rate limiting, and Content Security Policy configuration.
Next.js combines a React frontend with a Node.js server in a single framework. That integration is powerful, but it also means you need to think about security at multiple layers simultaneously: browser-facing headers, server-side API routes, server actions, and the boundary between what runs on the client versus the server.
This guide walks through the most important security controls for a production Next.js application.
1. Security Headers in next.config.ts
Next.js makes it straightforward to attach HTTP security headers to every response through the headers() configuration:
// next.config.ts
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
async headers() {
return [
{
source: '/(.*)',
headers: [
// Prevent clickjacking
{ key: 'X-Frame-Options', value: 'DENY' },
// Prevent MIME sniffing
{ key: 'X-Content-Type-Options', value: 'nosniff' },
// Restrict referrer information
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
// Restrict browser features
{
key: 'Permissions-Policy',
value: 'camera=(), microphone=(), geolocation=(), payment=()',
},
// Force HTTPS (set via your CDN for more coverage)
{
key: 'Strict-Transport-Security',
value: 'max-age=63072000; includeSubDomains; preload',
},
// Content Security Policy
{
key: 'Content-Security-Policy',
value: [
"default-src 'self'",
"script-src 'self' 'unsafe-eval'", // remove unsafe-eval if possible
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https:",
"font-src 'self'",
"connect-src 'self' https://api.yourdomain.com",
"object-src 'none'",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
].join('; '),
},
],
},
];
},
};
export default nextConfig;
Verify your headers after deployment using securityheaders.com or your ShipSafer security scan.
2. Protecting API Routes with Auth Middleware
Every API route under app/api/ is publicly reachable by default. Protect them with authentication middleware.
Middleware Approach
Next.js middleware runs before any route handler, making it ideal for auth checks:
// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
import { verifyToken } from '@/lib/auth';
const PROTECTED_PATTERNS = ['/api/admin', '/api/user', '/dashboard'];
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
const isProtected = PROTECTED_PATTERNS.some(p => pathname.startsWith(p));
if (!isProtected) return NextResponse.next();
const token = request.cookies.get('authToken')?.value;
if (!token) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const payload = verifyToken(token);
if (!payload) {
return NextResponse.json({ error: 'Invalid token' }, { status: 401 });
}
// Pass user info to the route handler via headers
const response = NextResponse.next();
response.headers.set('x-user-id', payload.userId);
response.headers.set('x-user-role', payload.role);
return response;
}
export const config = {
matcher: ['/api/:path*', '/dashboard/:path*'],
};
Per-Route Auth Check
For granular control, also check auth inside the route handler itself:
// app/api/admin/users/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { getAuthenticatedUser } from '@/lib/auth-middleware';
export async function GET(request: NextRequest) {
const user = await getAuthenticatedUser();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
if (user.role !== 'admin') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
// admin-only logic
}
3. Server Actions Security
Server actions are powerful — they run server-side code directly called from client components. This convenience makes it easy to forget that they are effectively API endpoints.
// app/actions/admin.ts
'use server';
import { getAuthenticatedUser } from '@/lib/auth-middleware';
import { z } from 'zod';
const DeleteUserSchema = z.object({
userId: z.string().uuid(),
});
export async function deleteUser(input: unknown) {
// 1. Authenticate
const caller = await getAuthenticatedUser();
if (!caller) return { success: false, error: 'Unauthorized' };
// 2. Authorize
if (caller.role !== 'admin') return { success: false, error: 'Forbidden' };
// 3. Validate input
const result = DeleteUserSchema.safeParse(input);
if (!result.success) return { success: false, error: 'Invalid input' };
// 4. Perform operation
await User.deleteOne({ userId: result.data.userId });
return { success: true };
}
Common mistakes with server actions:
- Forgetting to authenticate — the action is callable from any browser console.
- Trusting the data shape — always validate with Zod before use.
- Returning full error objects — return sanitized messages only.
4. Environment Variable Exposure
Next.js has two categories of environment variables:
| Prefix | Available in | Risk |
|---|---|---|
NEXT_PUBLIC_ | Browser + Server | Bundle-exposed |
| (no prefix) | Server only | Safe for secrets |
# .env.local
DATABASE_URL=mongodb+srv://... # Safe — server only
JWT_SECRET=supersecretkey # Safe — server only
STRIPE_SECRET_KEY=sk_live_... # Safe — server only
NEXT_PUBLIC_STRIPE_PUBLISHABLE=pk_live_ # Fine — intended for browser
NEXT_PUBLIC_DATABASE_URL=... # DANGEROUS — never do this
After every deployment, check whether secrets leaked:
# Search Next.js build artifacts
grep -r "sk_live" .next/static/chunks/
grep -r "mongodb+srv" .next/static/
server-only Imports
Use the server-only package to hard-fail at build time if a server module is imported client-side:
// lib/db.ts
import 'server-only';
import mongoose from 'mongoose';
export async function connectDB() { /* ... */ }
5. Content Security Policy with Nonces
A static CSP breaks with Next.js's inline script injection. The recommended approach is nonce-based CSP, where a random value authorizes each inline script for that specific request.
// middleware.ts
import { NextResponse } from 'next/server';
import crypto from 'crypto';
export function middleware(request: NextRequest) {
const nonce = crypto.randomBytes(16).toString('base64');
const csp = [
"default-src 'self'",
`script-src 'self' 'nonce-${nonce}'`,
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https:",
"object-src 'none'",
"frame-ancestors 'none'",
].join('; ');
const response = NextResponse.next();
response.headers.set('Content-Security-Policy', csp);
response.headers.set('x-nonce', nonce); // Pass to layout component
return response;
}
// app/layout.tsx
import { headers } from 'next/headers';
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const nonce = (await headers()).get('x-nonce') ?? '';
return (
<html>
<head>
<script nonce={nonce} src="/scripts/analytics.js" />
</head>
<body>{children}</body>
</html>
);
}
6. Rate Limiting API Routes
Without rate limiting, your API routes are vulnerable to brute-force attacks, credential stuffing, and abuse.
npm install @upstash/ratelimit @upstash/redis
// lib/rate-limit.ts
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
export const authLimiter = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(5, '15 m'),
prefix: 'ratelimit:auth',
});
export const apiLimiter = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(100, '1 m'),
prefix: 'ratelimit:api',
});
// app/api/auth/login/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { authLimiter } from '@/lib/rate-limit';
export async function POST(request: NextRequest) {
const ip = request.headers.get('x-forwarded-for') ?? 'unknown';
const { success, limit, remaining, reset } = await authLimiter.limit(ip);
if (!success) {
return NextResponse.json(
{ error: 'Too many requests. Please try again later.' },
{
status: 429,
headers: {
'RateLimit-Limit': limit.toString(),
'RateLimit-Remaining': remaining.toString(),
'RateLimit-Reset': reset.toString(),
'Retry-After': Math.ceil((reset - Date.now()) / 1000).toString(),
},
}
);
}
// proceed with login
}
7. Disable Unnecessary Next.js Features
// next.config.ts
const nextConfig: NextConfig = {
// Prevent exposing Next.js version
poweredByHeader: false,
// Restrict image domains to prevent SSRF via next/image
images: {
remotePatterns: [
{ protocol: 'https', hostname: 'cdn.yourdomain.com' },
// Do NOT add '*' — only allowlist domains you control
],
},
// Disable source maps in production (prevents source code exposure)
productionBrowserSourceMaps: false,
};
8. CORS for API Routes
By default, Next.js API routes accept requests from any origin. Set explicit CORS headers:
// app/api/public/route.ts
import { NextRequest, NextResponse } from 'next/server';
const ALLOWED_ORIGINS = ['https://yourdomain.com', 'https://app.yourdomain.com'];
export async function GET(request: NextRequest) {
const origin = request.headers.get('origin');
const allowedOrigin = origin && ALLOWED_ORIGINS.includes(origin) ? origin : ALLOWED_ORIGINS[0];
return NextResponse.json({ data: 'public data' }, {
headers: {
'Access-Control-Allow-Origin': allowedOrigin,
'Access-Control-Allow-Methods': 'GET',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Vary': 'Origin',
},
});
}
Summary Checklist
- Security headers configured in
next.config.ts(CSP, HSTS, X-Frame-Options, etc.) - API routes protected by authentication middleware
- Server actions authenticate and validate all inputs
- No secrets in
NEXT_PUBLIC_env vars -
server-onlyimports for modules with secrets - Rate limiting on auth and sensitive endpoints
-
poweredByHeader: falseandproductionBrowserSourceMaps: false - Image domains allowlisted (no wildcard)
- CORS explicitly configured for API routes
Next.js security is a layered problem. No single control is sufficient — combine headers, auth middleware, input validation, and dependency scanning to build a defense-in-depth posture for your application.