Next.js Security
Next.js moves fast and its security model has shifted with every major version. The most common failure mode is reaching for a familiar pattern from a year ago that no longer holds — middleware-only auth, naive Server Actions, NEXT_PUBLIC_ env handling. This skill is the spot-check list.
Tested patterns target App Router on Next.js 14+; most apply to 15/16 too. Pages Router callouts are marked.
When to invoke
- New Next.js app heading to production
- Major version upgrade (13 → 14 → 15 → 16)
- Adding authenticated routes, Server Actions, or new middleware logic
- Investigating an incident or suspicious request pattern
- Code review for an inherited Next.js codebase
1. Middleware is not authoritative auth
Middleware runs on every matched request, but it is a network-edge thing — not a substitute for per-route authorization. Treat middleware as a performance optimization for redirects and headers, not as a security boundary.
The 2025 middleware bypass class (CVE-2025-29927-style) showed that header smuggling can skip middleware entirely on misconfigured setups. Patch your Next.js to the fixed version and assume the bypass is possible — every protected route still re-checks auth server-side.
// app/admin/page.tsx — every protected route does its own check
import { redirect } from 'next/navigation';
import { getSession } from '@/lib/session';
export default async function AdminPage() {
const session = await getSession();
if (!session || session.role !== 'admin') redirect('/login');
// ... render
}
For Server Actions specifically, re-validate inside the action:
'use server';
export async function deletePost(id: string) {
const session = await getSession();
if (!session) throw new Error('unauthenticated');
if (!canDelete(session.userId, id)) throw new Error('forbidden');
// ... mutate
}
A Server Action is callable from any client that knows the action ID; middleware only sees the request, not the action target.
2. NEXT_PUBLIC_ env leakage
Any environment variable starting with NEXT_PUBLIC_ is baked into the client bundle. It is visible to anyone who opens DevTools.
# Find what's actually shipped
grep -r 'NEXT_PUBLIC_' .next/static/chunks 2>/dev/null | head
Common mistakes:
- A "publishable" API key that turns out to be more powerful than its name suggests (e.g. Supabase
anonkey without RLS, Sentry DSN with project-wide scope) - Internal admin URLs prefixed
NEXT_PUBLIC_ADMIN_API=... - Build-time secret values that the dev assumed were server-only because they "feel server-only"
Rule: if it would matter that a competitor or attacker sees it, do not prefix it NEXT_PUBLIC_. Use a Server Component / Route Handler / Server Action to mediate access.
3. Server Actions — input validation and rate limiting
Server Actions look like function calls but they are HTTP endpoints. Apply the same hygiene you would to any POST endpoint:
'use server';
import { z } from 'zod';
import { rateLimit } from '@/lib/rate-limit';
const schema = z.object({
title: z.string().min(1).max(200),
bodyHtml: z.string().max(50_000),
});
export async function createPost(input: unknown) {
const session = await requireSession();
await rateLimit(`create-post:${session.userId}`, { window: '1m', max: 5 });
const { title, bodyHtml } = schema.parse(input);
const safeHtml = sanitizeHtml(bodyHtml);
return db.posts.create({ title, bodyHtml: safeHtml, userId: session.userId });
}
Specifically:
- Validate every input with Zod / Valibot — never trust the call site
- Sanitize HTML before storing if it will be rendered (DOMPurify on server input, server-side renderer)
- Rate limit per user and per IP — Server Actions can be invoked from anywhere
- Avoid taking arbitrary identifiers — if the user "should only edit their own posts", do not accept arbitrary
postId; resolve it via session-scoped query
4. RSC over-fetch
React Server Components serialize fetched data and ship it to the client. It is easy to over-fetch and accidentally serialize fields the UI never displays.
// Bad — passes the whole user record (incl. password_hash, email_verified_at) to the client component
const user = await db.users.findUnique({ where: { id }});
return <UserCard user={user} />;
// Good — explicit projection
const user = await db.users.findUnique({
where: { id },
select: { id: true, displayName: true, avatarUrl: true },
});
return <UserCard user={user} />;
Audit checklist:
- Every server-component fetch uses an explicit
select/pick(or a typed DTO function) - No record types include credentials, internal flags, or PII not needed for render
JSON.stringifyof the page's RSC payload (visible in the HTML source) does not contain anything you would not put in a public API response
5. Content Security Policy
Inline scripts and styles are common in Next; CSP needs a strategy.
- App Router + nonce-based CSP is the modern path. Generate a per-request nonce in middleware, attach to the response header, and pass to
next/scriptvia thenonceprop. unsafe-inlineis a footgun but sometimes unavoidable for legacy components. If you use it, document why and revisit per release.script-src 'self' 'nonce-...'baseline; add specific allowed origins only as needed (analytics, etc.)frame-ancestors 'none'unless you intentionally support embeddingupgrade-insecure-requestsis cheap and helpful
Start in Content-Security-Policy-Report-Only for a week, then promote to enforcing.
6. Open redirects and unsafe redirect()
Any flow that takes a destination from user input is a potential open redirect:
// Bad
const next = searchParams.get('next');
redirect(next ?? '/');
// Good — allowlist or strip to relative path
function safeNext(next: string | null): string {
if (!next || !next.startsWith('/')) return '/';
if (next.startsWith('//')) return '/'; // protocol-relative would escape
return next;
}
Same logic for OAuth callback state, post-login destinations, and "return to" parameters.
7. next/image and remote patterns
next/image can fetch remote images on behalf of your server. If remotePatterns is too permissive, you have an SSRF + image-resize-amplification gift.
// next.config.js — bad
images: { remotePatterns: [{ protocol: 'https', hostname: '**' }] }
// Good
images: {
remotePatterns: [
{ protocol: 'https', hostname: 'cdn.example.com' },
{ protocol: 'https', hostname: 'images.unsplash.com', pathname: '/**' },
],
}
Never use domains: ['*'] or remotePatterns matching all hosts in production.
8. Route Handlers — auth and method discipline
API route handlers under app/api/*/route.ts are public unless protected:
// app/api/admin/users/route.ts
import { requireSession } from '@/lib/auth';
export async function GET() {
const session = await requireSession();
if (session.role !== 'admin') return new Response('forbidden', { status: 403 });
return Response.json(await db.users.findMany({ select: safeFields }));
}
// Be explicit about which methods exist. Unexported methods 405 by default — that's the desired behavior.
Mistakes to watch for:
- Route handlers that read body without size limit (DoS) — set a sensible cap
- CORS handlers that echo
Originback asAccess-Control-Allow-Origin(effectively allow-any) - JSON parse without catch — malformed body crashes the handler and leaks stack in dev
9. Edge runtime vs Node runtime
export const runtime = 'edge' changes the threat model:
- Smaller surface for some attacks (no Node APIs)
- But: many auth libraries assume Node crypto. Misconfigured edge auth has produced incidents.
- Secrets in Edge are available to the function but not "less exposed" than Node — same caution applies.
- Edge middleware has even tighter constraints. If your middleware imports
crypto.subtleand a buggy polyfill, you can get inconsistent behavior between local dev and prod.
Pick a runtime per route deliberately, document why, and stick to it.
10. Headers (server-side baseline)
For routes not behind Cloudflare with header injection, set in next.config.js:
async headers() {
return [{
source: '/(.*)',
headers: [
{ key: 'Strict-Transport-Security', value: 'max-age=31536000; includeSubDomains' },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
{ key: 'X-Frame-Options', value: 'DENY' },
],
}];
}
If you are behind Cloudflare, prefer setting these at the edge — see cloudflare-hardening.
11. Build-time and dependency hygiene
- Lockfile committed (
package-lock.json,pnpm-lock.yaml) — required for reproducible builds npm audit/pnpm auditin CI, fail on highnext telemetry disableif you do not want Next's anonymous telemetry- Pin Next.js patch versions in
package.json, do not rely on caret to pull a fix automatically
Quick checklist
- Patched against the latest Next.js advisories (middleware bypass class)
- Every protected route re-checks auth, not just middleware
- Every Server Action validates inputs and re-checks auth
- No
NEXT_PUBLIC_*env contains anything sensitive - RSC fetches use explicit field projection
- CSP applied, nonce-based preferred
-
redirect()destinations are validated against an allowlist or stripped to relative -
next/imageremotePatternsis a real allowlist, not** - Route handlers have size limits and explicit method exports
- Edge vs Node runtime chosen per route deliberately
- Security headers set (Next config or Cloudflare)
- Lockfile committed; audit gates in CI
What this skill will not do
- Help bypass Next.js authentication on apps you do not own
- Recommend disabling Next.js security features for convenience
- Cover Pages-Router-specific historical issues in depth (most legacy patterns are still vulnerable in their original form; migrate)