All guides
Engineering16 min read

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.

NC

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:

code
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.

code
// 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):

code
// 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
}
code
// 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.

code
// 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.

code
// ✅ 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.

code
// 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:

code
// ✅ 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:

code
// 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:

code
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

code
# 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 audit in 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.