Troja
All posts
Next.jsMar 13, 2026·20 min read

Next.js Security Best Practices: 10 Things Most Developers Miss

Next.js is secure by default — until you reach for client components, route handlers, and middleware. Here are ten places the framework's footguns hide, with the correct patterns.

By Security Desk

Secure defaults, sharp edges

Next.js does a lot right out of the box. But the App Router blurs the client/server line, and that's exactly where mistakes happen. Here are ten things developers consistently miss.

1. Server-only code leaking to the client

The boundary is invisible until it bites you. Anything imported into a Client Component — directly or transitively — ships to the browser. Mark server-only modules so a stray import fails the build instead of leaking:

import "server-only";

export async function getApiKeyAndCallVendor() {
  const key = process.env.VENDOR_SECRET; // never reaches the client now
}

If a Client Component imports this, the build breaks — which is exactly what you want.

2. NEXT_PUBLIC_ secrets

Any NEXT_PUBLIC_-prefixed env var is inlined into the client bundle. Never prefix a real secret. Audit:

grep -rn "NEXT_PUBLIC_" .env*

Only non-secret config (analytics IDs, public keys) belongs there.

3. Route Handlers are public by default

app/api/.../route.ts is reachable by anyone. There's no implicit auth. Check the session inside every handler:

import { auth } from "@/auth";

export async function POST(req: Request) {
  const session = await auth();
  if (!session) return new Response("Unauthorized", { status: 401 });
  // ... and then authorize the specific action
}

4. Authentication without authorization

Knowing who the user is isn't the same as knowing what they're allowed to do. The classic miss is fetching a record by ID with no ownership check:

// add this to every ID-taking handler / server action
if (record.userId !== session.user.id) {
  return new Response("Forbidden", { status: 403 });
}

5. Treating middleware as the auth layer

Middleware runs at the edge and is great for redirects, but it's the wrong place to enforce data access — it doesn't see your queries, and matcher misconfigurations can skip routes. Enforce authorization in the data layer (the Server Component or handler that reads the data), and use middleware only as a coarse first gate.

// middleware.ts — coarse gate only
export const config = { matcher: ["/dashboard/:path*", "/api/:path*"] };

6. Server Action authorization

Server Actions feel like local function calls, but they're public HTTP endpoints. Anyone can invoke them with arbitrary arguments. Validate input and authorize inside the action:

"use server";
import { z } from "zod";

export async function deleteProject(input: unknown) {
  const { id } = z.object({ id: z.string() }).parse(input);
  const session = await auth();
  const project = await db.project.findUnique({ where: { id } });
  if (!session || project?.ownerId !== session.user.id) throw new Error("Forbidden");
  await db.project.delete({ where: { id } });
}

Modern Next.js checks the request Origin against allowedOrigins to blunt CSRF on actions — but it does not authorize the action for you.

7. No Content Security Policy

CSP is your strongest XSS mitigation and it's off by default. Set it (with a nonce for inline scripts) in middleware or next.config.js:

// next.config.js
async headers() {
  return [{
    source: "/:path*",
    headers: [
      { key: "Content-Security-Policy", value: "default-src 'self'; object-src 'none'; base-uri 'self'" },
      { key: "X-Content-Type-Options", value: "nosniff" },
      { key: "X-Frame-Options", value: "DENY" },
      { key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains; preload" },
    ],
  }];
}

8. dangerouslySetInnerHTML on user content

React escapes by default — this opt-out is the one place XSS sneaks back in. Sanitize with DOMPurify or, better, don't render raw user HTML at all.

9. Open redirects

Reading a redirect target straight from the query string lets attackers bounce users to phishing pages:

// VULNERABLE
redirect(searchParams.next);

// SAFE — allowlist or force same-origin
const target = searchParams.next ?? "/";
if (!target.startsWith("/")) redirect("/");
redirect(target);

10. Leaking data through over-fetching

Server Components make it easy to pass an entire database row to the client. Select only the fields the UI needs, and never include password hashes, tokens, or internal flags in what you serialize to the browser.

Quick audit

  • server-only on secret-handling modules
  • No secrets behind NEXT_PUBLIC_
  • Auth + authorization in every route handler and action
  • Authorization enforced at the data layer, not only middleware
  • CSP and hardening headers set
  • No unsanitized dangerouslySetInnerHTML
  • Redirect targets validated
  • No over-fetched fields serialized to the client

Scan it with Troja

Troja checks your deployed Next.js app for leaked NEXT_PUBLIC_ secrets, unprotected route handlers, missing CSP, open redirects, and over-exposed data — with a fix prompt for each. Scan your production URL and close the gaps the framework left to you.

Run the scan this post is about.

Free, no signup. See what's hiding inside your walls in ~30 seconds.

Free scan · no signup · results in ~30 seconds
Next.js Security Best Practices: 10 Things Most Developers Miss — Troja