Next.js Security Checklist: 15 Must-Do Hardening Steps
A practical Next.js security checklist covering CSP headers, server action auth, env var exposure, middleware guards, and SSRF prevention.
Next.js Security Checklist: 15 Must-Do Hardening Steps
Next.js ships with sensible defaults, but defaults are not the same as secure. The App Router, Server Actions, and edge middleware introduce new attack surfaces that many teams miss entirely. This checklist covers the 15 most impactful hardening steps you can apply to any Next.js application today.
1. Set Security Headers in next.config
The fastest win. Add a headers() function to your next.config.ts to inject security headers on every response:
// next.config.ts
const nextConfig = {
async headers() {
return [
{
source: '/(.*)',
headers: [
{ 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=()' },
{
key: 'Strict-Transport-Security',
value: 'max-age=63072000; includeSubDomains; preload',
},
],
},
];
},
};
2. Configure a Content Security Policy
CSP is the primary defense against XSS. In Next.js, nonce-based CSP is the recommended approach because it works with server-rendered inline scripts:
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { randomBytes } from 'crypto';
export function middleware(request: NextRequest) {
const nonce = randomBytes(16).toString('base64');
const csp = `
default-src 'self';
script-src 'self' 'nonce-${nonce}' 'strict-dynamic';
style-src 'self' 'nonce-${nonce}';
img-src 'self' blob: data:;
connect-src 'self';
frame-ancestors 'none';
`.replace(/\s{2,}/g, ' ').trim();
const response = NextResponse.next();
response.headers.set('Content-Security-Policy', csp);
response.headers.set('x-nonce', nonce);
return response;
}
3. Authenticate Every Server Action
Server Actions are HTTP endpoints. Without explicit auth checks, any user — authenticated or not — can call them directly. Never rely on the UI to gate access:
'use server';
import { getAuthenticatedUser } from '@/lib/auth-middleware';
export async function deleteRecord(id: string) {
const user = await getAuthenticatedUser();
if (!user) {
return { success: false, error: 'Authentication required' };
}
// proceed
}
4. Validate Server Action Input with Zod
Server Actions receive raw POST data. Validate it before touching the database:
'use server';
import { z } from 'zod';
const schema = z.object({
email: z.string().email(),
role: z.enum(['viewer', 'editor']),
});
export async function inviteUser(data: unknown) {
const parsed = schema.safeParse(data);
if (!parsed.success) {
return { success: false, error: 'Invalid input' };
}
// parsed.data is now type-safe
}
5. Never Expose Server-Only Env Vars to the Client
Any variable prefixed with NEXT_PUBLIC_ is bundled into the client JavaScript. Do not prefix secrets:
# .env.local
DATABASE_URL=mongodb+srv://... # server-only, never exposed
JWT_SECRET=supersecret # server-only, never exposed
NEXT_PUBLIC_POSTHOG_KEY=phc_... # safe to expose
Audit your bundle with ANALYZE=true next build and the @next/bundle-analyzer package to catch accidental leaks.
6. Protect Routes with Middleware Auth Guards
Use middleware.ts to redirect unauthenticated users before the page renders:
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
const PROTECTED = ['/dashboard', '/settings', '/api/private'];
export function middleware(request: NextRequest) {
const isProtected = PROTECTED.some((path) =>
request.nextUrl.pathname.startsWith(path)
);
if (isProtected) {
const token = request.cookies.get('authToken');
if (!token) {
return NextResponse.redirect(new URL('/login', request.url));
}
}
return NextResponse.next();
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};
7. Prevent SSRF via Server Actions
Server Actions run on the server and can make internal HTTP requests. If you accept a URL from user input and fetch it, you are vulnerable to Server-Side Request Forgery:
'use server';
import { URL } from 'url';
const ALLOWED_HOSTS = new Set(['api.example.com', 'cdn.example.com']);
export async function fetchExternalData(urlString: string) {
let url: URL;
try {
url = new URL(urlString);
} catch {
return { success: false, error: 'Invalid URL' };
}
if (!ALLOWED_HOSTS.has(url.hostname)) {
return { success: false, error: 'Host not allowed' };
}
const response = await fetch(url.toString());
return { success: true, data: await response.json() };
}
Never allow requests to 169.254.169.254 (AWS IMDS), localhost, or private RFC-1918 ranges.
8. Use HTTP-Only Cookies for Session Tokens
Do not store JWTs in localStorage. Store them in HTTP-only cookies so JavaScript cannot read them:
import { cookies } from 'next/headers';
export function setAuthCookie(token: string) {
cookies().set({
name: 'authToken',
value: token,
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7,
path: '/',
});
}
9. Add CSRF Protection for Mutations
Next.js Server Actions include built-in CSRF protection via origin checking, but custom API routes (/api/*) do not. Add explicit CSRF tokens or verify the Origin header:
// app/api/webhook/route.ts
export async function POST(request: Request) {
const origin = request.headers.get('origin');
const allowedOrigin = process.env.APP_URL;
if (origin !== allowedOrigin) {
return new Response('Forbidden', { status: 403 });
}
// handle request
}
10. Sanitize HTML Before Rendering
If you must render user-supplied HTML, sanitize it with DOMPurify:
import DOMPurify from 'isomorphic-dompurify';
// Safe
const clean = DOMPurify.sanitize(userHtml);
return <div dangerouslySetInnerHTML={{ __html: clean }} />;
Never pass raw user input to dangerouslySetInnerHTML.
11. Disable x-powered-by and Server Headers
Next.js removes X-Powered-By by default, but confirm it stays off:
// next.config.ts
const nextConfig = {
poweredByHeader: false,
};
12. Restrict API Routes to Authenticated Requests
Every API route handler should verify the session before processing:
// app/api/data/route.ts
import { getAuthenticatedUser } from '@/lib/auth-middleware';
export async function GET() {
const user = await getAuthenticatedUser();
if (!user) {
return new Response('Unauthorized', { status: 401 });
}
// return data
}
13. Set cache: 'no-store' on Sensitive Fetch Calls
Server Components that fetch sensitive data can have responses cached by the CDN. Opt out:
const data = await fetch('https://api.example.com/user/profile', {
cache: 'no-store',
headers: { Authorization: `Bearer ${token}` },
});
14. Audit Third-Party Dependencies Regularly
npm audit
npx snyk test
npx better-npm-audit audit
Lock your dependency versions and run audits in CI. A compromised dependency has the same access as your own code.
15. Enable TypeScript Strict Mode
TypeScript strict mode catches null dereferences and type mismatches that could become runtime vulnerabilities:
// tsconfig.json
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true
}
}
Quick Reference
| Step | Impact | Effort |
|---|---|---|
| Security headers | High | Low |
| CSP nonce | High | Medium |
| Server action auth | Critical | Low |
| Zod validation | High | Low |
| HTTP-only cookies | High | Low |
| Middleware auth guard | High | Low |
| SSRF URL allowlist | High | Low |
npm audit in CI | Medium | Low |
Work through this list top-to-bottom. The first six items can be implemented in an afternoon and eliminate the most common attack vectors in Next.js applications.