Next.js ships with sensible routing defaults but zero security headers. A freshly scaffolded Next.js app scores F on securityheaders.com. This guide fixes that — in order of impact.
Everything here is applicable to an existing Next.js 13+ App Router project. Most changes take under 30 minutes.
1. Security headers via next.config.ts
The highest-leverage change: a single block of headers that protects against clickjacking, MIME sniffing, information leakage, and enforces HTTPS.
// next.config.ts
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
async headers() {
return [
{
source: '/(.*)',
headers: [
// Prevent MIME sniffing
{ key: 'X-Content-Type-Options', value: 'nosniff' },
// Prevent clickjacking (use CSP frame-ancestors for full control)
{ key: 'X-Frame-Options', value: 'SAMEORIGIN' },
// Stop IE from activating dangerous content
{ key: 'X-XSS-Protection', value: '0' }, // Disable — CSP replaces this
// Control referrer leakage
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
// Restrict browser APIs available to your page and iframes
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=(), browsing-topics=()' },
// Enforce HTTPS for 1 year, include subdomains
{ key: 'Strict-Transport-Security', value: 'max-age=31536000; includeSubDomains; preload' },
],
},
];
},
};
export default nextConfig;Note: X-XSS-Protection: 0 intentionally disables the legacy XSS auditor in older browsers. Modern browsers don't use it, and it has known bypasses. CSP is the correct replacement.
2. Content-Security-Policy with nonces
This is the hardest part of Next.js security, but it's the most important. Without CSP, an XSS vulnerability — in your code or any dependency — gives an attacker full access to your users' sessions.
The recommended approach for App Router is nonce-based CSP via middleware:
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
// Generate a fresh cryptographic nonce for every request
const nonce = Buffer.from(crypto.randomUUID()).toString('base64');
const csp = [
`default-src 'self'`,
// Nonce covers root bundle; strict-dynamic propagates trust to dynamic imports
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
// Unsafe-inline only in style-src — acceptable because CSS can't exfiltrate tokens
`style-src 'self' 'unsafe-inline'`,
`img-src 'self' blob: data: https:`,
`font-src 'self'`,
`connect-src 'self'`,
`object-src 'none'`,
`base-uri 'self'`,
`form-action 'self'`,
`frame-ancestors 'none'`,
`upgrade-insecure-requests`,
].join('; ');
const requestHeaders = new Headers(request.headers);
requestHeaders.set('x-nonce', nonce);
const response = NextResponse.next({ request: { headers: requestHeaders } });
response.headers.set('Content-Security-Policy', csp);
return response;
}
export const config = {
// Exclude static assets — they don't need CSP, and processing them wastes CPU
matcher: [
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
};Then read the nonce in your root layout and pass it to the script tag that bootstraps Next.js:
// app/layout.tsx
import { headers } from 'next/headers';
import type { ReactNode } from 'react';
export default async function RootLayout({ children }: { children: ReactNode }) {
const headersList = await headers();
const nonce = headersList.get('x-nonce') ?? '';
return (
<html lang="en">
<body>{children}</body>
</html>
);
}If you use Google Analytics, Google Fonts, or other third-party scripts, you'll need to add their domains to the appropriate directives (script-src, connect-src, font-src, img-src). Use CSP Evaluator to validate your final policy.
3. Third-party scripts with nonces
Next.js Script component supports nonces directly:
// app/layout.tsx
import Script from 'next/script';
import { headers } from 'next/headers';
export default async function RootLayout({ children }: { children: ReactNode }) {
const nonce = (await headers()).get('x-nonce') ?? '';
return (
<html>
<body>
{children}
<Script
src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXX"
strategy="afterInteractive"
nonce={nonce}
/>
</body>
</html>
);
}And add GTM to your CSP:
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic' https://www.googletagmanager.com`,
`connect-src 'self' https://www.google-analytics.com`,
`img-src 'self' blob: data: https://www.google-analytics.com`,4. Secure cookies
Every cookie in your application should have explicit security attributes. A session cookie without HttpOnly is readable by any JavaScript on the page — including XSS payloads.
// app/api/auth/route.ts
import { cookies } from 'next/headers';
import type { NextResponse } from 'next/server';
export async function POST(request: Request) {
const cookieStore = await cookies();
// Session token — never accessible to JS
cookieStore.set('session', sessionToken, {
httpOnly: true, // JS cannot read this
secure: true, // HTTPS only
sameSite: 'lax', // CSRF protection (use 'strict' if no cross-site nav needed)
path: '/',
maxAge: 60 * 60 * 24 * 7, // 7 days
});
// Preference cookies — can be JS-accessible, but still secure
cookieStore.set('theme', 'dark', {
httpOnly: false, // OK — not sensitive
secure: true,
sameSite: 'lax',
path: '/',
maxAge: 60 * 60 * 24 * 365,
});
}SameSite values:
strict— cookie never sent on cross-site requests (breaks OAuth flows)lax— cookie sent on top-level navigation GET requests only (recommended default)none— cookie always sent cross-site; requiresSecureto be set
5. Environment variable hygiene
Next.js has a sharp edge: any variable prefixed with NEXT_PUBLIC_ is embedded in the client bundle and visible to every visitor.
# .env.local
NEXT_PUBLIC_API_URL=https://api.example.com # ✓ fine — not a secret
NEXT_PUBLIC_SITE_NAME=My App # ✓ fine
DATABASE_URL=postgres://... # ✓ fine — server only
STRIPE_SECRET_KEY=sk_live_... # ✓ fine — server only
# NEVER do these:
NEXT_PUBLIC_STRIPE_SECRET_KEY=sk_live_... # ✗ exposed to every visitor
NEXT_PUBLIC_DATABASE_URL=postgres://... # ✗ exposed to every visitor
NEXT_PUBLIC_OPENAI_KEY=sk-... # ✗ exposed to every visitorCheck your deployed site with PatchVex to detect accidentally exposed API keys in HTTP responses — this catches keys embedded in HTML, JavaScript bundles, and API responses.
6. API route authorization
Every Next.js API route is publicly accessible by default. Never assume a route is internal:
// app/api/admin/users/route.ts
import { auth } from '@/lib/auth';
import { NextResponse } from 'next/server';
export async function GET(request: Request) {
const session = await auth(request);
// Always check auth before doing anything
if (!session || session.role !== 'admin') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Now it's safe to proceed
const users = await db.user.findMany();
return NextResponse.json(users);
}For internal APIs called only by your own frontend, verify the Origin header:
export async function POST(request: Request) {
const origin = request.headers.get('origin');
const allowed = process.env.NEXT_PUBLIC_SITE_URL;
if (origin !== allowed) {
return new Response(null, { status: 403 });
}
// ...
}7. CORS for API routes
If you're building an API consumed by other origins, be explicit:
// app/api/public/route.ts
const ALLOWED_ORIGINS = [
'https://app.example.com',
'https://example.com',
// Add staging/dev origins only in non-production
...(process.env.NODE_ENV !== 'production' ? ['http://localhost:3000'] : []),
];
function corsHeaders(origin: string | null) {
const isAllowed = origin && ALLOWED_ORIGINS.includes(origin);
return {
'Access-Control-Allow-Origin': isAllowed ? origin : '',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Max-Age': '86400',
'Vary': 'Origin',
};
}
export async function OPTIONS(request: Request) {
const origin = request.headers.get('origin');
return new Response(null, {
status: 204,
headers: corsHeaders(origin),
});
}
export async function GET(request: Request) {
const origin = request.headers.get('origin');
return NextResponse.json({ data: '...' }, {
headers: corsHeaders(origin),
});
}Never set Access-Control-Allow-Origin: * on endpoints that use cookies or Authorization headers. The * wildcard is incompatible with Access-Control-Allow-Credentials: true and won't work anyway — the browser will block it.
8. Rate limiting
Without rate limiting, your API endpoints are open to brute force, credential stuffing, and enumeration. Next.js doesn't include rate limiting — add it in middleware:
// middleware.ts (extend the existing one)
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
import { NextResponse } from 'next/server';
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(10, '10 s'),
});
export async function middleware(request: NextRequest) {
// Rate limit API routes only
if (request.nextUrl.pathname.startsWith('/api/')) {
const ip = request.ip ?? '127.0.0.1';
const { success, limit, remaining } = await ratelimit.limit(ip);
if (!success) {
return NextResponse.json(
{ error: 'Too many requests' },
{
status: 429,
headers: {
'X-RateLimit-Limit': limit.toString(),
'X-RateLimit-Remaining': remaining.toString(),
'Retry-After': '10',
},
}
);
}
}
// ... rest of middleware
}9. Dependency hygiene
Run npm audit before every production deploy. Treat high-severity vulnerabilities as blocking:
# Check for vulnerabilities
npm audit
# Fix automatically (safe fixes only)
npm audit fix
# See what's fixable vs requires manual intervention
npm audit --json | jq '.vulnerabilities | to_entries[] | select(.value.severity == "high" or .value.severity == "critical")'Set up Dependabot or Renovate to open PRs for security updates automatically.
10. Verify with PatchVex
After deploying these changes, scan your site to confirm everything is configured correctly:
- Go to patchvex.com/products/scanner
- Enter your production URL
- Review the security report — it checks all the controls covered in this guide
Pay particular attention to:
- CSP presence and quality (nonce vs unsafe-inline)
- Cookie attributes on Set-Cookie headers
- HSTS max-age and includeSubDomains
- Any API keys appearing in HTTP responses
Quick reference checklist
| Control | Status after this guide |
|---|---|
| X-Content-Type-Options | ✓ nosniff |
| X-Frame-Options | ✓ SAMEORIGIN |
| Referrer-Policy | ✓ strict-origin-when-cross-origin |
| Permissions-Policy | ✓ Restrictive defaults |
| Strict-Transport-Security | ✓ 1 year + includeSubDomains |
| Content-Security-Policy | ✓ Nonce-based |
| Cookie HttpOnly | ✓ On all session cookies |
| Cookie Secure | ✓ On all cookies |
| Cookie SameSite | ✓ lax or strict |
| API authorization | ✓ Checked on every route |
| CORS | ✓ Explicit allowlist |
| Rate limiting | ✓ On API routes |
| Environment variables | ✓ No secrets in NEXT_PUBLIC_ |