Security Hardening for Next.js
Security is not a feature you bolt on at the end of a project; it is a property of the whole system. In Next.js, the line between server and client is porous by design: Server Components run during rendering, Server Actions accept mutations from the browser, route handlers expose HTTP endpoints, and middleware sits at the edge. Each layer is an opportunity to enforce security and a chance to get it wrong.
The guiding principle for a secure Next.js application is server-first verification. Anything the client sends is untrusted until validated on the server. Cookies, headers, form data, query strings, JSON bodies, and uploaded files are all attack surfaces. The server is the only place that knows the real session state, the real secrets, and the real business rules.
This guide covers defense in depth for Next.js 16: security headers and CSP, input validation and output escaping, CSRF and XSS prevention, authentication security, environment variable hygiene, dependency supply chain safety, and server-side protections such as SSRF and injection prevention. The goal is a practical, production-ready security posture that protects users without sacrificing performance.
HTTP response headers are the first line of defense. They tell the browser how to behave, restrict what can be loaded, and block entire classes of attacks before any JavaScript runs. Next.js lets you configure headers in next.config.ts and in middleware.ts. Use both: config for global defaults, middleware for request-specific values such as CSP nonces.
A solid baseline includes Strict-Transport-Security to enforce HTTPS, X-Frame-Options or frame-ancestors to prevent clickjacking, X-Content-Type-Options to stop MIME sniffing, and Referrer-Policy to control leaked referrer information. Add Permissions-Policy to disable browser features you do not need, such as microphone or geolocation.
| 1 | // next.config.ts |
| 2 | import type { NextConfig } from "next"; |
| 3 | |
| 4 | const nextConfig: NextConfig = { |
| 5 | async headers() { |
| 6 | return [ |
| 7 | { |
| 8 | source: "/(.*)", |
| 9 | headers: [ |
| 10 | { |
| 11 | key: "Strict-Transport-Security", |
| 12 | value: "max-age=63072000; includeSubDomains; preload", |
| 13 | }, |
| 14 | { |
| 15 | key: "X-Frame-Options", |
| 16 | value: "SAMEORIGIN", |
| 17 | }, |
| 18 | { |
| 19 | key: "X-Content-Type-Options", |
| 20 | value: "nosniff", |
| 21 | }, |
| 22 | { |
| 23 | key: "Referrer-Policy", |
| 24 | value: "strict-origin-when-cross-origin", |
| 25 | }, |
| 26 | { |
| 27 | key: "Permissions-Policy", |
| 28 | value: "camera=(), microphone=(), geolocation=(), interest-cohort=()", |
| 29 | }, |
| 30 | { |
| 31 | key: "X-DNS-Prefetch-Control", |
| 32 | value: "on", |
| 33 | }, |
| 34 | ], |
| 35 | }, |
| 36 | ]; |
| 37 | }, |
| 38 | }; |
| 39 | |
| 40 | export default nextConfig; |
Use next.config.ts for global static headers and middleware.ts only when a value must be dynamic, such as a CSP nonce. Middleware runs on the edge with limited Node.js APIs, so keep it lightweight.
| Header | Purpose | Recommended value |
|---|---|---|
| Strict-Transport-Security | Enforce HTTPS and prevent downgrade attacks | max-age=63072000; includeSubDomains; preload |
| X-Frame-Options | Prevent clickjacking by controlling iframe embedding | SAMEORIGIN or DENY |
| X-Content-Type-Options | Disable MIME sniffing for user uploads | nosniff |
| Referrer-Policy | Limit referrer leakage to third parties | strict-origin-when-cross-origin |
| Permissions-Policy | Disable unused browser features | camera=(), microphone=(), geolocation=() |
| Content-Security-Policy | Control loaded scripts, styles, and resources | See next section |
note
Content Security Policy (CSP) is the most powerful header for preventing cross-site scripting and data injection. It tells the browser exactly which sources are allowed for scripts, styles, images, fonts, connections, frames, and other resources. A strict CSP can block inline scripts, inline styles, and external scripts from unexpected domains, even if an attacker finds a way to inject HTML.
The most secure CSP avoids 'unsafe-inline' for scripts. Because Next.js injects inline scripts for things like data hydration and error overlays, you must use a nonce: a random token generated per request that is added to both the CSP header and the <script> tags. Next.js supports this through middleware and the nonce option on script components.
| 1 | // middleware.ts |
| 2 | import { NextResponse } from "next/server"; |
| 3 | import type { NextRequest } from "next/server"; |
| 4 | |
| 5 | export function middleware(request: NextRequest) { |
| 6 | const nonce = Buffer.from(crypto.randomUUID()).toString("base64"); |
| 7 | |
| 8 | const cspHeader = ` |
| 9 | default-src 'self'; |
| 10 | script-src 'self' 'nonce-${nonce}' 'strict-dynamic'; |
| 11 | style-src 'self' 'nonce-${nonce}'; |
| 12 | img-src 'self' blob: data:; |
| 13 | font-src 'self'; |
| 14 | object-src 'none'; |
| 15 | base-uri 'self'; |
| 16 | form-action 'self'; |
| 17 | frame-ancestors 'none'; |
| 18 | upgrade-insecure-requests; |
| 19 | `; |
| 20 | |
| 21 | const response = NextResponse.next({ |
| 22 | request: { |
| 23 | headers: new Headers(request.headers), |
| 24 | }, |
| 25 | }); |
| 26 | |
| 27 | response.headers.set("Content-Security-Policy", cspHeader.replace(/\s+/g, " ").trim()); |
| 28 | response.headers.set("x-nonce", nonce); |
| 29 | |
| 30 | return response; |
| 31 | } |
| 32 | |
| 33 | export const config = { |
| 34 | matcher: ["/((?!_next/static|_next/image|favicon.ico|.*\.\w+$).*)"], |
| 35 | }; |
In your layout, read the nonce from the request header and pass it to Script components or any inline scripts you control. Next.js uses the nonce automatically for its own inline scripts when it is present in the request headers. Never expose the nonce in a way that allows an attacker to predict it.
| 1 | // app/layout.tsx |
| 2 | import Script from "next/script"; |
| 3 | import { headers } from "next/headers"; |
| 4 | |
| 5 | export default async function RootLayout({ |
| 6 | children, |
| 7 | }: { |
| 8 | children: React.ReactNode; |
| 9 | }) { |
| 10 | const nonce = (await headers()).get("x-nonce") ?? undefined; |
| 11 | |
| 12 | return ( |
| 13 | <html lang="en"> |
| 14 | <body> |
| 15 | {children} |
| 16 | <Script |
| 17 | src="/analytics.js" |
| 18 | strategy="afterInteractive" |
| 19 | nonce={nonce} |
| 20 | /> |
| 21 | </body> |
| 22 | </html> |
| 23 | ); |
| 24 | } |
Deploy CSP in stages. Start with Content-Security-Policy-Report-Only and collect violations through a report-uri endpoint. Once the report noise drops to zero, switch to the enforcing header.
danger
Every input is a potential attack vector. Next.js receives data from URL params, query strings, cookies, headers, JSON bodies, and FormData. The server must validate shape, type, length, and allowed values before using any input. TypeScript alone is not enough: it disappears at runtime, and attackers can send anything.
Zod is the most common choice in the Next.js ecosystem because it integrates cleanly with TypeScript. Define schemas at every server boundary: route handlers, Server Actions, and API clients. Reuse schemas on the client only for UX, and never trust client-side validation as a security control. Server Actions receive FormData by default, so validate expected fields and coerce types before use.
| 1 | // app/api/posts/route.ts |
| 2 | import { NextResponse } from "next/server"; |
| 3 | import { createPostSchema } from "@/lib/validation"; |
| 4 | |
| 5 | export async function POST(request: Request) { |
| 6 | const parsed = createPostSchema.safeParse(await request.json()); |
| 7 | |
| 8 | if (!parsed.success) { |
| 9 | return NextResponse.json({ error: "Invalid input" }, { status: 400 }); |
| 10 | } |
| 11 | |
| 12 | const post = await db.post.create({ data: parsed.data }); |
| 13 | return NextResponse.json(post, { status: 201 }); |
| 14 | } |
XSS prevention is a combination of validation and output encoding. Next.js escapes JSX output by default, which protects against many injection attacks. However, when you dangerously set inner HTML or render markdown, you must sanitize first. Libraries like DOMPurify for client HTML or sanitization libraries for markdown are essential. Never concatenate user input into HTML strings.
| 1 | // Unsafe โ never do this |
| 2 | export default function Unsafe({ html }: { html: string }) { |
| 3 | return <div dangerouslySetInnerHTML={{ __html: html }} />; |
| 4 | } |
| 5 | |
| 6 | // Safer โ sanitize first |
| 7 | import DOMPurify from "isomorphic-dompurify"; |
| 8 | |
| 9 | export default function Safer({ html }: { html: string }) { |
| 10 | const clean = DOMPurify.sanitize(html, { |
| 11 | ALLOWED_TAGS: ["p", "br", "strong", "em", "a"], |
| 12 | ALLOWED_ATTR: ["href"], |
| 13 | }); |
| 14 | return <div dangerouslySetInnerHTML={{ __html: clean }} />; |
| 15 | } |
best practice
Cross-Site Request Forgery tricks an authenticated user into performing an unintended action on your site. The attacker embeds a malicious link or form on a third-party domain; the browser sends the victim's cookies along with the request, and the server accepts it as legitimate. Mutations that change state must verify that the request came from your own application.
Next.js Server Actions include CSRF protection by default because they are bound to a same-origin request and require a valid session. However, custom route handlers, external API endpoints, and forms submitted to third-party services need explicit guards. The strongest protection combines origin validation, same-site cookies, and anti-CSRF tokens for sensitive mutations.
| 1 | // app/api/unsafe/route.ts |
| 2 | import { NextResponse } from "next/server"; |
| 3 | import type { NextRequest } from "next/server"; |
| 4 | |
| 5 | const allowedOrigins = [ |
| 6 | "https://forgelearn.dev", |
| 7 | "https://app.forgelearn.dev", |
| 8 | ]; |
| 9 | |
| 10 | export async function POST(request: NextRequest) { |
| 11 | const origin = request.headers.get("origin"); |
| 12 | const referer = request.headers.get("referer"); |
| 13 | |
| 14 | // Reject requests without a trusted origin |
| 15 | if (!origin || !allowedOrigins.includes(origin)) { |
| 16 | return NextResponse.json({ error: "Invalid origin" }, { status: 403 }); |
| 17 | } |
| 18 | |
| 19 | // Optional: also check referer matches origin |
| 20 | if (!referer || !referer.startsWith(origin)) { |
| 21 | return NextResponse.json({ error: "Invalid referer" }, { status: 403 }); |
| 22 | } |
| 23 | |
| 24 | // Proceed with mutation |
| 25 | return NextResponse.json({ success: true }); |
| 26 | } |
Cookies are the transport for session state, so their attributes matter. SameSite=Lax blocks cross-site POST requests in modern browsers, which stops most CSRF attacks. SameSite=Strict is stronger but can break legitimate top-level navigation from external links. HttpOnly prevents JavaScript from reading the cookie, and Secure ensures it is only sent over HTTPS.
| 1 | // Setting a secure session cookie in a route handler |
| 2 | import { cookies } from "next/headers"; |
| 3 | import { NextResponse } from "next/server"; |
| 4 | |
| 5 | export async function POST() { |
| 6 | const sessionToken = await createSession(); |
| 7 | const cookieStore = await cookies(); |
| 8 | |
| 9 | cookieStore.set("session", sessionToken, { |
| 10 | httpOnly: true, |
| 11 | secure: process.env.NODE_ENV === "production", |
| 12 | sameSite: "lax", |
| 13 | maxAge: 60 * 60 * 24, // 1 day |
| 14 | path: "/", |
| 15 | }); |
| 16 | |
| 17 | return NextResponse.json({ success: true }); |
| 18 | } |
For operations outside Server Actions, issue a cryptographically random anti-CSRF token per session or per form. Store it in an HttpOnly cookie or embed it as a hidden field, and validate it on the server for every state-changing request.
warning
Authentication security is about keeping sessions and credentials out of attacker hands. Even when you use a library like Auth.js or Clerk, you must understand the defaults and decide whether they match your threat model. Short-lived tokens, secure cookies, session rotation, and brute-force protection are the pillars of a production auth system.
Session cookies should be HttpOnly, Secure, and scoped with SameSite. Access tokens should be short-lived; refresh tokens can be longer but must rotate. Invalidate existing sessions on password change and log-out-from-all-devices. Brute-force protection should apply before authentication logic, using a Redis or database counter keyed by IP or account, and should return the same generic error for invalid credentials and locked accounts.
| 1 | // auth.ts with secure session configuration |
| 2 | import NextAuth from "next-auth"; |
| 3 | import Google from "next-auth/providers/google"; |
| 4 | |
| 5 | export const { handlers, auth, signIn, signOut } = NextAuth({ |
| 6 | providers: [Google], |
| 7 | session: { |
| 8 | strategy: "database", |
| 9 | maxAge: 24 * 60 * 60, // 1 day |
| 10 | updateAge: 60 * 60, // rotate every hour |
| 11 | }, |
| 12 | cookies: { |
| 13 | sessionToken: { |
| 14 | name: "__Secure-authjs.session-token", |
| 15 | options: { |
| 16 | httpOnly: true, |
| 17 | sameSite: "lax", |
| 18 | path: "/", |
| 19 | secure: true, |
| 20 | }, |
| 21 | }, |
| 22 | }, |
| 23 | }); |
Brute-force protection should apply before authentication logic, using a Redis or database counter keyed by IP or account, and should return the same generic error for invalid credentials and locked accounts to prevent user enumeration.
danger
Next.js distinguishes between server and client environment variables by the NEXT_PUBLIC_ prefix. Any variable with that prefix is inlined into the client bundle at build time and is visible to anyone who opens the browser dev tools. This is convenient for public configuration like API endpoints or analytics keys, but it is a security hole for secrets.
Keep secrets out of the client bundle entirely. Do not prefix database URLs, signing keys, API secrets, or private tokens with NEXT_PUBLIC_. Read them only in server code such as Server Components, route handlers, Server Actions, and middleware. If a value is sensitive, it should never be referenced in a client component or a .env variable that is sent to the browser.
| 1 | # .env.local โ safe examples |
| 2 | DATABASE_URL="postgresql://user:pass@host/db" |
| 3 | AUTH_SECRET="replace-with-a-cryptographically-random-secret" |
| 4 | STRIPE_SECRET_KEY="sk_live_..." |
| 5 | OPENAI_API_KEY="sk-..." |
| 6 | |
| 7 | # .env.local โ public examples, OK to expose in client bundle |
| 8 | NEXT_PUBLIC_APP_URL="https://forgelearn.dev" |
| 9 | NEXT_PUBLIC_POSTHOG_KEY="phc_..." |
| 10 | |
| 11 | # Never do this |
| 12 | NEXT_PUBLIC_STRIPE_SECRET_KEY="sk_live_..." |
| 13 | NEXT_PUBLIC_DATABASE_URL="postgresql://..." |
Runtime checks catch missing secrets early. A missing database URL or signing key should fail the build or the first request, not manifest as a cryptic runtime error. Create a small validation module that parses process.env with a schema and throws if required values are absent. This pattern also gives you typed access to environment variables.
| 1 | // lib/env.ts |
| 2 | import { z } from "zod"; |
| 3 | |
| 4 | const envSchema = z.object({ |
| 5 | DATABASE_URL: z.string().min(1), |
| 6 | AUTH_SECRET: z.string().min(32), |
| 7 | STRIPE_SECRET_KEY: z.string().startsWith("sk_"), |
| 8 | NEXT_PUBLIC_APP_URL: z.string().url(), |
| 9 | }); |
| 10 | |
| 11 | export const env = envSchema.parse(process.env); |
For production, consider a secrets manager instead of plain .env files. Services like AWS Secrets Manager, Azure Key Vault, Google Secret Manager, or HashiCorp Vault provide rotation, access auditing, and fine-grained permissions. If you must use .env files, never commit them to version control, restrict file permissions, and rotate leaked credentials immediately.
warning
Third-party dependencies extend your attack surface. Every package you install can run build scripts, access the network, and read files during installation. A compromised dependency can exfiltrate secrets, inject malicious code into your bundle, or install additional malware. Supply chain security is about minimizing dependency exposure and detecting problems quickly.
Start by keeping dependencies current. Use npm outdated and npm audit regularly, treat high and critical advisories as deployment blockers, and review package-lock.json changes in every pull request. Lockfile integrity ensures reproducible installs and helps detect supply chain tampering.
Lockfile integrity is critical. The package-lock.json ensures that every install produces the same dependency tree. Commit it and review changes carefully. Be selective about authentication and security libraries: a small, well-audited library with a clear maintainer is usually safer than a large dependency that does more than you need.
pro tip
Server-side code has direct access to databases, external APIs, and the file system. This power makes it dangerous. Injection attacks, unsafe redirects, server-side request forgery, and malicious file uploads are classic server-side vulnerabilities that Next.js applications are not immune to. The fix is always the same: validate, restrict, and never pass raw user input to powerful APIs.
SQL injection is prevented by using parameterized queries or an ORM. Never concatenate user input into SQL strings. Prisma, Drizzle, and other ORMs escape values automatically. If you write raw SQL, use prepared statements with placeholders. The same principle applies to NoSQL queries: use validated values, not raw user input, in query objects.
| 1 | // Unsafe โ never do this |
| 2 | const user = await db.$queryRaw` |
| 3 | SELECT * FROM users WHERE email = '${email}' |
| 4 | `; |
| 5 | |
| 6 | // Safe โ parameterized query with Prisma |
| 7 | const user = await db.user.findUnique({ |
| 8 | where: { email: validatedEmail }, |
| 9 | }); |
| 10 | |
| 11 | // Safe โ raw SQL with placeholders |
| 12 | const result = await db.$queryRaw` |
| 13 | SELECT * FROM users WHERE email = ${validatedEmail} |
| 14 | `; |
Server-Side Request Forgery (SSRF) happens when an attacker supplies a URL that your server then fetches, reaching internal services or cloud metadata endpoints. Always validate URLs against an allowlist of domains and protocols. Reject private IP ranges, localhost, and internal hostnames. Prefer pre-configured endpoints over user-supplied URLs whenever possible.
| 1 | // lib/url-validate.ts |
| 2 | import { z } from "zod"; |
| 3 | |
| 4 | export const safeUrlSchema = z.string().url().refine((url) => { |
| 5 | try { |
| 6 | const parsed = new URL(url); |
| 7 | const blockedHosts = ["localhost", "127.0.0.1", "::1", "169.254.169.254"]; |
| 8 | return parsed.protocol === "https:" && !blockedHosts.includes(parsed.hostname); |
| 9 | } catch { |
| 10 | return false; |
| 11 | } |
| 12 | }, { message: "URL is not allowed" }); |
Open redirects are another common issue. Validate redirect targets against an allowlist of paths or domains, or use internal path-only redirects. Never redirect to a fully qualified URL from user input without validation.
File uploads require extra care. Validate file types by inspecting magic bytes, not just the extension. Limit file size, store uploads outside the web root, rename them to random identifiers, and serve them through a controlled route rather than a direct URL. Never execute uploaded files or use their names directly in file system paths.
best practice
Use this checklist before shipping a Next.js application to production. It is not exhaustive, but it covers the most common and high-impact security controls. Go through every item, verify it in your environment, and document any exceptions with a risk assessment.
| Category | Check | Status |
|---|---|---|
| Headers | HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy are set | โก |
| CSP | Content Security Policy is enforced and reports are monitored | โก |
| CSP | Scripts use nonces or hashes; unsafe-inline is avoided | โก |
| Input | All route handlers and Server Actions validate input with Zod | โก |
| XSS | User content is sanitized before rendering as HTML | โก |
| CSRF | Session cookies are SameSite and HttpOnly | โก |
| Auth | Tokens are short-lived and sessions rotate on key events | โก |
| Auth | Login and sensitive endpoints are rate-limited | โก |
| Secrets | No secrets use the NEXT_PUBLIC_ prefix | โก |
| Secrets | Environment variables are validated at runtime | โก |
| Dependencies | npm audit passes with no critical or high issues | โก |
| Dependencies | package-lock.json is committed and reviewed | โก |
| Server | Database queries are parameterized or use an ORM | โก |
| Server | External URLs, redirects, and uploads are validated | โก |
| HTTPS | Production uses HTTPS with HSTS preload | โก |
info