🔐 Security in Next.js: The Complete Guide

Building a secure Next.js application requires defense at every layer — from how you handle authentication and cookies, to how you validate input and configure HTTP headers. This tutorial walks through the most important security concerns specific to Next.js, with practical, production-ready patterns for each.

Danger

Security is never "done". Treat this guide as a foundation, and stay current with the official Next.js security documentation and the OWASP Top 10.

📖 1. Introduction

Next.js blurs the line between client and server, which means security decisions apply differently depending on where your code runs. A value that's safe to expose in a Server Component can be catastrophic if leaked into a Client Component bundle.

Security Surface Areas
Authentication & Sessions
Data Handling (Input/Output)
Network (Headers, CORS, HTTPS)
Infrastructure (Secrets, Rate Limiting)

🧱 2. Security Fundamentals

Most security failures trace back to a small set of broken principles. Keeping these in mind guides nearly every decision below.

  • Never trust client input— validate and sanitize everything on the server, regardless of client-side checks.
  • Least privilege— grant users and services only the access they strictly need.
  • Defense in depth— layer multiple protections so a single failure doesn't compromise the app.
  • Fail securely— errors should default to denying access, not granting it.

🔑 3. Authentication Security

Authentication confirms who a user is. In Next.js, this typically happens via a library like NextAuth.js (Auth.js), Clerk, or a custom token-based flow handled in Route Handlers and Server Actions.

Verifying a session in a Server Component

import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";

export default async function DashboardPage() {
  const session = await auth();

  if (!session) {
    redirect("/login");
  }

  return <div>Welcome, {session.user.name}</div>;
}

Important

Always verify authentication on the server, even for pages that also check auth client-side for UX purposes. Client-side checks alone are trivially bypassed.

🛂 4. Authorization Security

Authorization determines what an authenticated user is allowed to do. Every protected action should check permissions independently, rather than assuming a prior check was sufficient.

app/actions.ts

"use server";

export async function deletePost(postId: string) {
  const session = await auth();
  if (!session) throw new Error("Unauthorized");

  const post = await db.post.findUnique({ where: { id: postId } });

  if (post?.authorId !== session.user.id && session.user.role !== "admin") {
    throw new Error("Forbidden: you do not own this post");
  }

  await db.post.delete({ where: { id: postId } });
}

Warning

Hiding a button in the UI is not authorization. Every Server Action and Route Handler must independently re-check permissions server-side.

đŸŽĢ 5. Session Security

Sessions track an authenticated user across requests. Poor session management — predictable IDs, missing expiration, or no rotation — is a common attack vector.

  1. Use cryptographically random, high-entropy session identifiers.
  2. Set a reasonable session expiration and enforce re-authentication for sensitive actions.
  3. Rotate session tokens after login and after privilege changes (e.g. password reset).
  4. Invalidate sessions server-side on logout — don't rely solely on clearing a client cookie.

đŸĒ 6. Cookie Security

Cookies storing session tokens must be configured with strict flags to resist theft and tampering.

Setting a secure cookie

import { cookies } from "next/headers";

const cookieStore = await cookies();

cookieStore.set("session", token, {
  httpOnly: true,
  secure: true,
  sameSite: "lax",
  path: "/",
  maxAge: 60 * 60 * 24 * 7, // 7 days
});
FlagPurpose
httpOnlyPrevents JavaScript from reading the cookie, blocking XSS-based theft
secureEnsures the cookie is only sent over HTTPS
sameSiteRestricts cross-site sending, mitigating CSRF

đŸ›Ąī¸ 7. CSRF Protection

CSRF tricks an authenticated user's browser into submitting an unwanted request. Server Actions include built-in protection, but custom Route Handlers need explicit safeguards.

Information

Next.js Server Actions automatically include origin checking for POST requests, rejecting calls whose origin doesn't match the deployment's host.

Manual origin check in a Route Handler

export async function POST(request: Request) {
  const origin = request.headers.get("origin");
  const allowedOrigin = process.env.NEXT_PUBLIC_APP_URL;

  if (origin !== allowedOrigin) {
    return new Response("Forbidden", { status: 403 });
  }

  // Handle the request
}

Best Practice

Combine sameSite: "lax" cookies with origin checks for layered CSRF defense, especially on state-changing endpoints like password changes or payments.

âš”ī¸ 8. XSS Prevention

XSS injects malicious scripts into a page, often to steal cookies or session tokens. React escapes content by default, but a few patterns reopen the risk.

Dangerous vs. safe rendering

// ❌ Dangerous: bypasses React's automatic escaping
<div dangerouslySetInnerHTML={{ __html: userInput }} />

// ✅ Safe: React escapes this automatically
<div>{userInput}</div>

Danger

If you must render HTML from user input, sanitize it first with a library like DOMPurify, and never trust the source, even if it "looks" like internal content.

📜 9. Content Security Policy (CSP)

A CSP header restricts which sources of scripts, styles, and other resources a browser is allowed to load, acting as a strong second layer of defense against XSS.

next.config.js — Content-Security-Policy header

const cspHeader = `
  default-src 'self';
  script-src 'self' 'nonce-${nonce}' 'strict-dynamic';
  style-src 'self' 'unsafe-inline';
  img-src 'self' blob: data:;
  connect-src 'self';
  frame-ancestors 'none';
`;

module.exports = {
  async headers() {
    return [
      {
        source: "/(.*)",
        headers: [
          { key: "Content-Security-Policy", value: cspHeader.replace(/\n/g, "") },
        ],
      },
    ];
  },
};

Tip

Use a per-request nonce generated in middleware for inline scripts, rather than relying on 'unsafe-inline', which significantly weakens the policy.

💉 10. SQL Injection Prevention

SQL injection occurs when untrusted input is concatenated directly into a query string. Always use parameterized queries or a trusted ORM.

Safe vs. unsafe query construction

// ❌ Vulnerable: string concatenation
const user = await db.raw(`SELECT * FROM users WHERE email = '${email}'`);

// ✅ Safe: parameterized query
const user = await db.raw("SELECT * FROM users WHERE email = ?", [email]);

// ✅ Safer: ORM with built-in parameterization
const user = await prisma.user.findUnique({ where: { email } });

Danger

Never build SQL queries with template string interpolation of user input, even for seemingly "safe" fields like sort order or column names — validate those against an allowlist instead.

đŸ—ƒī¸ 11. NoSQL Injection Prevention

NoSQL databases like MongoDB are vulnerable to a different flavor of injection, where attacker-controlled objects (not strings) alter query logic.

Vulnerable NoSQL query

// ❌ Vulnerable: if req.body.password is { "$ne": null },
// this bypasses the password check entirely
const user = await db.collection("users").findOne({
  email: req.body.email,
  password: req.body.password,
});

Warning

Always validate that incoming fields are the expected primitive type (string, number) before passing them into a query, rejecting objects or arrays where a scalar is expected.

✅ 12. Input Validation

Validate every piece of external input — form submissions, query params, headers, uploaded files — at the server boundary, using a schema validation library like zod.

app/actions.ts

"use server";

import { z } from "zod";

const CreateUserSchema = z.object({
  email: z.string().email(),
  age: z.number().int().min(13).max(120),
  username: z.string().min(3).max(20).regex(/^[a-zA-Z0-9_]+$/),
});

export async function createUser(input: unknown) {
  const result = CreateUserSchema.safeParse(input);

  if (!result.success) {
    return { success: false, errors: result.error.flatten() };
  }

  // result.data is now fully typed and validated
}

Best Practice

Validate on the server even if you also validate on the client. Client-side validation is a UX convenience, not a security boundary.

🔤 13. Output Encoding

Encoding output ensures data is rendered as inert text rather than executable markup or code, depending on the context it's placed in.

  • HTML context: React escapes text content automatically — avoid dangerouslySetInnerHTML.
  • URL context: use encodeURIComponent() before inserting values into query strings.
  • Attribute context: avoid injecting unsanitized values into href or src attributes.

Caution

Watch for javascript: URLs in user-supplied href values — validate that links use expected protocols like https: before rendering them.

🔑 14. Environment Variable Security

Environment variables prefixed with NEXT_PUBLIC_ are bundled into client-side JavaScript and visible to anyone who views your site's source.

.env — correct vs. incorrect prefixing

# ✅ Safe to expose to the browser
NEXT_PUBLIC_API_URL=https://api.example.com

# ❌ Never prefix secrets with NEXT_PUBLIC_
NEXT_PUBLIC_DATABASE_URL=postgres://user:pass@host/db  # WRONG
DATABASE_URL=postgres://user:pass@host/db              # Correct — server-only

Danger

Before adding a NEXT_PUBLIC_ prefix to any variable, ask: "Am I comfortable with this value appearing in view-source on every visitor's browser?"

đŸ—ī¸ 15. Secrets Management

API keys, database credentials, and signing secrets should never live in source control. Use your hosting platform's environment variable management or a dedicated secrets manager.

  1. Store secrets in your platform's dashboard (Vercel, AWS Secrets Manager, HashiCorp Vault) rather than in code.
  2. Use different secrets for development, staging, and production environments.
  3. Rotate secrets periodically, and immediately after any suspected leak.
  4. Add .env* files to .gitignore before the first commit.

Warning

If a secret is ever accidentally committed to Git, rotating itis mandatory — removing it from the latest commit alone is not sufficient since it remains in the repository's history.

🌐 16. API Security

Every API surface — Route Handlers, Server Actions, and external integrations — needs consistent authentication, authorization, and validation.

Securing an API Endpoint
Authenticate the requester
Authorize the specific action
Validate and sanitize input
Rate limit to prevent abuse
Return minimal, non-sensitive error details

🔌 17. Route Handler Security

Route Handlers are standard HTTP endpoints and inherit all the classic web API risks — they need explicit checks that aren't automatically applied.

app/api/admin/users/route.ts

import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";

export async function GET(request: NextRequest) {
  const session = await auth();

  if (!session || session.user.role !== "admin") {
    return NextResponse.json({ error: "Forbidden" }, { status: 403 });
  }

  const users = await db.user.findMany({ select: { id: true, email: true } });
  return NextResponse.json(users);
}

Important

Unlike Server Actions, Route Handlers get no automatic CSRF protection. Add explicit origin or token checks for state-changing endpoints.

⚡ 18. Server Action Security

Server Actions are exposed as public endpoints under the hood, even though they're called like regular functions. Never assume a Server Action is only reachable from your own UI.

app/actions.ts

"use server";

export async function updateUserRole(userId: string, newRole: string) {
  const session = await auth();

  // Re-check authorization inside every Server Action —
  // it can be called directly, bypassing any UI restrictions
  if (session?.user.role !== "admin") {
    throw new Error("Unauthorized");
  }

  const validRoles = ["user", "editor", "admin"];
  if (!validRoles.includes(newRole)) {
    throw new Error("Invalid role");
  }

  await db.user.update({ where: { id: userId }, data: { role: newRole } });
}

Danger

Treat every Server Action as a public API endpoint. An attacker can call it directly with arbitrary arguments, regardless of what your UI normally sends.

đŸšĻ 19. Middleware Security

Middleware runs before a request reaches a page or Route Handler, making it a natural place for authentication gating, header injection, and bot filtering.

middleware.ts

import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

export function middleware(request: NextRequest) {
  const token = request.cookies.get("session")?.value;

  if (request.nextUrl.pathname.startsWith("/dashboard") && !token) {
    return NextResponse.redirect(new URL("/login", request.url));
  }

  return NextResponse.next();
}

export const config = {
  matcher: ["/dashboard/:path*"],
};

Caution

Middleware checks are a UX conveniencefor redirecting unauthenticated users early — they do not replace server-side authorization checks inside the actual page or action.

📁 20. File Upload Security

File uploads introduce risks including malicious file execution, storage exhaustion, and path traversal. Validate rigorously before accepting any file.

  1. Restrict accepted file types by checking the actual file signature, not just the extension or Content-Type header.
  2. Enforce a strict maximum file size on both client and server.
  3. Store uploads outside the web root, or in dedicated object storage like S3.
  4. Generate randomized filenames rather than trusting user-supplied names.
  5. Scan uploads for malware if the app accepts files from untrusted users.

Warning

A file named image.jpg.php or one with a spoofed Content-Typecan bypass naive extension checks — always validate the actual file content.

🌍 21. CORS

CORS controls which external origins are allowed to call your API from a browser. Overly permissive CORS exposes your endpoints to any website.

app/api/public/route.ts

import { NextResponse } from "next/server";

const allowedOrigins = ["https://trusted-partner.com"];

export async function GET(request: Request) {
  const origin = request.headers.get("origin") ?? "";
  const response = NextResponse.json({ data: "public info" });

  if (allowedOrigins.includes(origin)) {
    response.headers.set("Access-Control-Allow-Origin", origin);
  }

  return response;
}

Danger

Avoid setting Access-Control-Allow-Origin: * on any endpoint that returns user-specific or sensitive data — it allows any website to read the response.

🧾 22. Security Headers

A small set of HTTP response headers meaningfully reduce common attack surfaces with minimal effort.

HeaderProtects Against
X-Frame-Options: DENYClickjacking via iframe embedding
X-Content-Type-Options: nosniffMIME-type sniffing attacks
Referrer-Policy: strict-origin-when-cross-originLeaking full URLs to third-party sites
Strict-Transport-SecurityProtocol downgrade and cookie-hijacking attacks
Permissions-PolicyUnwanted access to camera, microphone, geolocation

next.config.js

module.exports = {
  async headers() {
    return [
      {
        source: "/(.*)",
        headers: [
          { key: "X-Frame-Options", value: "DENY" },
          { key: "X-Content-Type-Options", value: "nosniff" },
          { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
          { key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains; preload" },
        ],
      },
    ];
  },
};

🔒 23. HTTPS

HTTPS encrypts traffic between the browser and server, preventing eavesdropping and tampering. It is a baseline requirement, not an optional hardening step.

  • Managed platforms like Vercel provision and renew TLS certificates automatically.
  • Self-hosted deployments should use certbot or a reverse proxy with automatic renewal.
  • Redirect all http:// traffic to https:// and enable HSTS.

Important

Cookies marked secureare silently dropped over plain HTTP — another reason HTTPS must be enforced everywhere, including internal tools.

🚧 24. Rate Limiting

Rate limiting throttles how many requests a client can make in a given window, protecting against brute-force login attempts, scraping, and denial-of-service abuse.

Rate limiting with Upstash Redis

import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";

const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(10, "60 s"),
});

export async function POST(request: Request) {
  const ip = request.headers.get("x-forwarded-for") ?? "unknown";
  const { success } = await ratelimit.limit(ip);

  if (!success) {
    return new Response("Too many requests", { status: 429 });
  }

  // Handle the request
}

Best Practice

Apply stricter limits to sensitive endpoints like login and password reset than to general read-only API routes.

🤖 25. Bot Protection

Automated bots probe for vulnerabilities, scrape content, and abuse forms at scale. Layer multiple defenses rather than relying on a single technique.

  • Add CAPTCHA or an invisible challenge (like Cloudflare Turnstile) on public forms.
  • Use honeypot fields — hidden inputs that only bots fill in — to silently reject spam submissions.
  • Combine rate limiting with bot detection for login and signup endpoints.
  • Monitor for unusual traffic patterns, such as identical requests at inhuman speed.

🔐 26. Authentication Best Practices

  1. Hash passwords with a slow, salted algorithm like bcrypt or argon2— never store them in plain text.
  2. Offer and encourage MFA for sensitive accounts.
  3. Lock or delay accounts after repeated failed login attempts.
  4. Use generic error messages like "Invalid credentials" rather than revealing whether the email or password was wrong.
  5. Send email notifications for sensitive account changes like password resets and new device logins.

Tip

Prefer battle-tested libraries like Auth.js, Clerk, or Luciaover rolling your own authentication system — auth code is easy to get subtly wrong.

📡 27. Security Monitoring

Monitoring turns security from a one-time setup into an ongoing practice by surfacing suspicious activity in real time.

  • Log authentication failures, permission denials, and rate-limit triggers with enough context to investigate.
  • Integrate an error-tracking tool like Sentry to catch unexpected exceptions that could indicate an exploit attempt.
  • Set up alerts for anomalies — spikes in 401/403 responses, unusual geographic access patterns, or repeated failed logins.

Note

Never log sensitive values like passwords, full session tokens, or credit card numbers — even in error logs intended only for internal debugging.

🐛 28. Common Vulnerabilities

VulnerabilityRoot CauseMitigation
Broken access controlMissing server-side authorization checksRe-verify permissions in every action and handler
XSSUnsanitized HTML renderingAvoid dangerouslySetInnerHTML, add CSP
Secrets exposureMisused NEXT_PUBLIC_ prefixAudit env vars before every deploy
Injection attacksUnparameterized queriesUse an ORM or parameterized queries exclusively
Insecure direct object referencesTrusting client-supplied IDs without ownership checksVerify the requester owns or can access the resource

📋 29. Security Checklist

To Do

  • All Server Actions and Route Handlers re-check authentication and authorization.
  • No secrets are prefixed with NEXT_PUBLIC_.
  • Cookies use httpOnly, secure, and sameSite flags.
  • A Content Security Policy is configured and tested.
  • All database queries use parameterization or an ORM.
  • Rate limiting is applied to authentication and public form endpoints.
  • Security headers (HSTS, X-Frame-Options, etc.) are set for all responses.
  • File uploads validate type, size, and content before storage.
  • Dependencies are audited regularly with npm audit.

❓ 30. Frequently Asked Questions

Question

Are Server Actions automatically protected against CSRF?

Answer

Yes, for the standard case — Next.js checks that the request origin matches the app's host. However, this doesn't replace proper authorization checks inside the action itself.

Question

Is it safe to trust data from a cookie without verification?

Answer

No. Always cryptographically sign or verify session tokens (e.g. with a JWT signature check) rather than trusting raw cookie values, which can be forged by a client.

Question

Do I need CSP if I already sanitize all user input?

Answer

Yes. CSP is a defense-in-depth layer that limits the damage of an XSS bug you didn't catch, such as one introduced by a third-party dependency.

📌 31. Summary

Security in Next.js is a shared responsibilityacross authentication, authorization, input handling, and infrastructure configuration. No single control is sufficient on its own — the strongest applications layer multiple defenses so that one failure doesn't become a breach.

Summary

  • Re-check authentication and authorization on the server for every Server Action and Route Handler.
  • Never expose secrets through the NEXT_PUBLIC_ prefix.
  • Validate and sanitize all external input; encode all output appropriately for its context.
  • Configure security headers, CSP, and HTTPS as non-negotiable baselines.
  • Rate limit sensitive endpoints and monitor for suspicious activity continuously.
>>"Security isn't a feature you ship once — it's a discipline you practice with every line of code."