|$ curl https://forge-ai.dev/api/markdown?path=docs/nextjs/security
$cat docs/security-hardening-for-next.js.md
updated Last weekยท30 min readยทpublished

Security Hardening for Next.js

โ—†Next.jsโ—†Securityโ—†Productionโ—†Intermediate to Advanced๐ŸŽฏFree Tools
Introduction

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.

Security Headers

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.

next.config.ts
TypeScript
1// next.config.ts
2import type { NextConfig } from "next";
3
4const 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
40export 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.

HeaderPurposeRecommended value
Strict-Transport-SecurityEnforce HTTPS and prevent downgrade attacksmax-age=63072000; includeSubDomains; preload
X-Frame-OptionsPrevent clickjacking by controlling iframe embeddingSAMEORIGIN or DENY
X-Content-Type-OptionsDisable MIME sniffing for user uploadsnosniff
Referrer-PolicyLimit referrer leakage to third partiesstrict-origin-when-cross-origin
Permissions-PolicyDisable unused browser featurescamera=(), microphone=(), geolocation=()
Content-Security-PolicyControl loaded scripts, styles, and resourcesSee next section
๐Ÿ“

note

Headers in next.config.ts are applied at build time for static pages and at request time for dynamic pages. Middleware headers override config headers when both are set. Use config for static values and middleware for dynamic ones.
Content Security Policy

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.

middleware_csp.ts
TypeScript
1// middleware.ts
2import { NextResponse } from "next/server";
3import type { NextRequest } from "next/server";
4
5export function middleware(request: NextRequest) {
6 const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
7
8 const cspHeader = `
9 default-src &apos;self&apos;;
10 script-src &apos;self&apos; &apos;nonce-${nonce}&apos; &apos;strict-dynamic&apos;;
11 style-src &apos;self&apos; &apos;nonce-${nonce}&apos;;
12 img-src &apos;self&apos; blob: data:;
13 font-src &apos;self&apos;;
14 object-src &apos;none&apos;;
15 base-uri &apos;self&apos;;
16 form-action &apos;self&apos;;
17 frame-ancestors &apos;none&apos;;
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
33export 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.

layout.tsx
TSX
1// app/layout.tsx
2import Script from "next/script";
3import { headers } from "next/headers";
4
5export 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

Avoid 'unsafe-inline' in script-src. It effectively disables XSS protection for scripts. If you need inline scripts, use a nonce or hash. 'unsafe-eval' is also dangerous and should only be enabled if a library strictly requires it, such as some WebAssembly bootstrappers.
Input Validation & Sanitization

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.

route.ts
TypeScript
1// app/api/posts/route.ts
2import { NextResponse } from "next/server";
3import { createPostSchema } from "@/lib/validation";
4
5export 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.

xss_prevention.tsx
TSX
1// Unsafe โ€” never do this
2export default function Unsafe({ html }: { html: string }) {
3 return <div dangerouslySetInnerHTML={{ __html: html }} />;
4}
5
6// Safer โ€” sanitize first
7import DOMPurify from "isomorphic-dompurify";
8
9export 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

Validate early, validate strictly, and fail closed. Use Zod schemas for every inbound data boundary. Treat any input that fails validation as an attack and return a generic 400 error. Do not reveal schema internals in error messages.
CSRF Protection

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.

origin_check.ts
TypeScript
1// app/api/unsafe/route.ts
2import { NextResponse } from "next/server";
3import type { NextRequest } from "next/server";
4
5const allowedOrigins = [
6 "https://forgelearn.dev",
7 "https://app.forgelearn.dev",
8];
9
10export 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.

secure_cookie.ts
TypeScript
1// Setting a secure session cookie in a route handler
2import { cookies } from "next/headers";
3import { NextResponse } from "next/server";
4
5export 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

Origin and referer checks are not foolproof. Some browsers or proxies strip the referer, and older browsers ignore SameSite. For high-risk operations, combine cookie attributes with explicit anti-CSRF tokens. Server Actions provide this combination automatically for App Router mutations.
Authentication Security

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.

auth_secure_session.ts
TypeScript
1// auth.ts with secure session configuration
2import NextAuth from "next-auth";
3import Google from "next-auth/providers/google";
4
5export 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

Never store session tokens in localStorage or sessionStorage. They are accessible to any JavaScript running on the page, including malicious scripts injected through XSS. Always use HttpOnly cookies for session state.
Environment Variables & Secrets

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.

.env.local
Bash
1# .env.local โ€” safe examples
2DATABASE_URL="postgresql://user:pass@host/db"
3AUTH_SECRET="replace-with-a-cryptographically-random-secret"
4STRIPE_SECRET_KEY="sk_live_..."
5OPENAI_API_KEY="sk-..."
6
7# .env.local โ€” public examples, OK to expose in client bundle
8NEXT_PUBLIC_APP_URL="https://forgelearn.dev"
9NEXT_PUBLIC_POSTHOG_KEY="phc_..."
10
11# Never do this
12NEXT_PUBLIC_STRIPE_SECRET_KEY="sk_live_..."
13NEXT_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.

env.ts
TypeScript
1// lib/env.ts
2import { z } from "zod";
3
4const 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
11export 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

Vercel and other hosting providers expose environment variables at build time and runtime. Mark sensitive variables as "Sensitive" or "Secret" in the dashboard so they are not visible in the UI or logs. Treat dashboard values with the same care as .env files.
Dependency & Supply Chain

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

Run npm install --ignore-scripts when you want to install dependencies without executing lifecycle scripts. This reduces the chance that a malicious package runs code during install. Then review the scripts before running them manually.
Server-Side Security

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.

sql_injection.ts
TypeScript
1// Unsafe โ€” never do this
2const user = await db.$queryRaw`
3 SELECT * FROM users WHERE email = &apos;${email}&apos;
4`;
5
6// Safe โ€” parameterized query with Prisma
7const user = await db.user.findUnique({
8 where: { email: validatedEmail },
9});
10
11// Safe โ€” raw SQL with placeholders
12const 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.

ssrf_prevention.ts
TypeScript
1// lib/url-validate.ts
2import { z } from "zod";
3
4export 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

Treat every server-side API as a public boundary. Route handlers, Server Actions, and API routes should all validate input, check authentication, and enforce authorization. The server is the only place where security decisions are trustworthy.
Security Checklist

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.

CategoryCheckStatus
HeadersHSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy are setโ–ก
CSPContent Security Policy is enforced and reports are monitoredโ–ก
CSPScripts use nonces or hashes; unsafe-inline is avoidedโ–ก
InputAll route handlers and Server Actions validate input with Zodโ–ก
XSSUser content is sanitized before rendering as HTMLโ–ก
CSRFSession cookies are SameSite and HttpOnlyโ–ก
AuthTokens are short-lived and sessions rotate on key eventsโ–ก
AuthLogin and sensitive endpoints are rate-limitedโ–ก
SecretsNo secrets use the NEXT_PUBLIC_ prefixโ–ก
SecretsEnvironment variables are validated at runtimeโ–ก
Dependenciesnpm audit passes with no critical or high issuesโ–ก
Dependenciespackage-lock.json is committed and reviewedโ–ก
ServerDatabase queries are parameterized or use an ORMโ–ก
ServerExternal URLs, redirects, and uploads are validatedโ–ก
HTTPSProduction uses HTTPS with HSTS preloadโ–ก
โ„น

info

Automate the checklist where possible. Run npm audit, type checking, and CSP report review in CI. Manual checklists are useful, but automated gates catch regressions before they reach production.
$Blueprint โ€” Engineering DocumentationยทSection ID: NEXT-SECยทRevision: 1.0