Next.js Security Guide: Protecting Your App Router Application
A comprehensive security guide for Next.js applications — covering CSP headers, SSRF prevention, injection attacks, auth security, and secrets management.
Nextcraft Agency
Security as Architecture
Security in a Next.js application isn't a single feature you add — it's a set of decisions baked into the architecture from the start. This guide covers the most common attack vectors and how to defend against them.
HTTP Security Headers
The fastest security improvement you can make is configuring HTTP security headers. Add them in next.config.ts:
const securityHeaders = [
{
key: 'X-DNS-Prefetch-Control',
value: 'on',
},
{
key: 'Strict-Transport-Security',
value: 'max-age=63072000; includeSubDomains; preload',
},
{
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=()',
},
]
const config: NextConfig = {
async headers() {
return [
{
source: '/(.*)',
headers: securityHeaders,
},
]
},
}
Content Security Policy
CSP is the most powerful XSS prevention mechanism available in browsers. It tells the browser exactly which sources are allowed to load scripts, styles, and other resources.
// A strict CSP for Next.js
const csp = `
default-src 'self';
script-src 'self' 'nonce-{NONCE}' https://js.stripe.com;
style-src 'self' 'unsafe-inline';
img-src 'self' data: blob: https:;
font-src 'self';
connect-src 'self' https://api.stripe.com https://vitals.vercel-insights.com;
frame-src https://js.stripe.com https://hooks.stripe.com;
frame-ancestors 'none';
form-action 'self';
base-uri 'self';
`.replace(/\n/g, '')
Use nonces for inline scripts (required for Next.js's inline script injection):
// middleware.ts — generate a nonce per request
import { NextResponse } from 'next/server'
import crypto from 'crypto'
export function middleware(request: NextRequest) {
const nonce = crypto.randomBytes(16).toString('base64')
const cspHeader = buildCsp(nonce)
const response = NextResponse.next()
response.headers.set('Content-Security-Policy', cspHeader)
response.headers.set('x-nonce', nonce) // pass to layout
return response
}
// app/layout.tsx — read nonce for script tags
import { headers } from 'next/headers'
export default async function RootLayout({ children }) {
const nonce = (await headers()).get('x-nonce') ?? ''
return (
<html>
<head>
<script nonce={nonce} dangerouslySetInnerHTML={{ __html: `...` }} />
</head>
<body>{children}</body>
</html>
)
}
Input Validation
Never trust user input. Validate at every boundary where external data enters your system.
// lib/validations.ts
import { z } from 'zod'
export const CreatePostSchema = z.object({
title: z.string().min(1).max(200).trim(),
content: z.string().min(1).max(50000),
categoryId: z.string().uuid(),
})
// In a Server Action or Route Handler:
export async function createPost(formData: FormData) {
const raw = {
title: formData.get('title'),
content: formData.get('content'),
categoryId: formData.get('categoryId'),
}
const parsed = CreatePostSchema.safeParse(raw)
if (!parsed.success) {
return { error: parsed.error.flatten() }
}
// Now safe to use parsed.data
await db.insert(posts).values(parsed.data)
}
SQL Injection Prevention
Using an ORM (Drizzle, Prisma) or parameterised queries prevents SQL injection by construction — never interpolate user input into raw SQL strings.
// ✅ Safe — parameterised
const user = await db.query.users.findFirst({
where: eq(users.email, userInput),
})
// ✅ Safe — Drizzle raw with parameterisation
const result = await db.execute(
sql`SELECT * FROM users WHERE email = ${userInput}`
)
// ❌ Dangerous — string interpolation
const result = await db.execute(`SELECT * FROM users WHERE email = '${userInput}'`)
SSRF Prevention
Server-Side Request Forgery happens when your server makes HTTP requests to URLs controlled by a user. An attacker can use this to reach internal services, cloud metadata endpoints (AWS 169.254.169.254), or private network resources.
// lib/safe-fetch.ts
const ALLOWED_ORIGINS = ['api.stripe.com', 'api.openai.com', 'your-cdn.com']
export async function safeFetch(url: string, options?: RequestInit) {
const parsed = new URL(url) // throws on invalid URL
// Block private/internal IP ranges
if (isPrivateIp(parsed.hostname)) {
throw new Error('Requests to private IP ranges are not allowed')
}
// Allowlist external origins
if (!ALLOWED_ORIGINS.includes(parsed.hostname)) {
throw new Error(`Host ${parsed.hostname} is not in the allowlist`)
}
return fetch(url, options)
}
Always use safeFetch when the URL comes from user input or external data.
Authentication Security
Session tokens in cookies, not localStorage:
// ✅ Secure cookie
response.cookies.set('session', token, {
httpOnly: true, // not accessible by JavaScript
secure: true, // HTTPS only
sameSite: 'lax', // CSRF protection
maxAge: 60 * 60 * 24 * 7, // 7 days
path: '/',
})
httpOnly cookies are invisible to JavaScript — XSS attacks can't steal them.
Validate session on the server on every request:
// Don't trust the client's user ID claim
export async function getCurrentUser(request: Request) {
const sessionToken = getCookieFromRequest(request, 'session')
if (!sessionToken) return null
// Look up the session in your database
const session = await db.query.sessions.findFirst({
where: and(
eq(sessions.token, sessionToken),
gt(sessions.expiresAt, new Date()),
),
with: { user: true },
})
return session?.user ?? null
}
Constant-time token comparison:
import crypto from 'crypto'
// ✅ Timing-safe comparison
const isValid = crypto.timingSafeEqual(
Buffer.from(providedToken),
Buffer.from(storedToken),
)
// ❌ Vulnerable to timing attacks
const isValid = providedToken === storedToken
Secrets Management
Never commit secrets. Use environment variables, and validate them at startup.
For production, use your platform's secret management:
- Vercel: Environment Variables with scoped access
- AWS: Secrets Manager or Parameter Store
- Self-hosted: HashiCorp Vault or external secrets operator
Rotate keys on a schedule and immediately when a team member with access leaves.
Dependency Security
# Audit dependencies for known vulnerabilities
npm audit
# Fix automatically where possible
npm audit fix
# Check for outdated packages
npx npm-check-updates
Add npm audit --audit-level=high to your CI pipeline. A failed audit should block deployment.
OWASP Top 10 Checklist
- ✅ Broken Access Control — validate permissions on every server request
- ✅ Cryptographic Failures — HTTPS, encrypted storage, httpOnly cookies
- ✅ Injection — parameterised queries, input validation with Zod
- ✅ Insecure Design — threat model your auth flows
- ✅ Security Misconfiguration — security headers, no default credentials
- ✅ Vulnerable Components —
npm auditin CI - ✅ Auth Failures — server-side session validation, rate limiting on auth endpoints
- ✅ Data Integrity Failures — verify webhook signatures (Stripe, GitHub)
- ✅ Logging Failures — log auth events, suspicious requests (not sensitive data)
- ✅ SSRF — allowlist external requests, block private IP ranges
Security is never finished. Run a threat model session every 6 months, and treat security incidents as data about your gaps rather than failures to hide.
Deepen your knowledge
Master your stack.
Explore more technical guides or start a direct conversation with our team.