Troja
All posts
Next.jsUpdated Jul 18, 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
Next.js Security Best Practices: 10 Things Most Developers Miss — Troja security, SEO and AI-visibility field guide

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

Centralize authorization in a data-access layer

Next.js recommends treating Server Components and Server Actions as server-side code with explicit authorization, not as a trusted private zone. Put database reads and mutations behind a small data-access layer that accepts the current identity, selects only necessary fields and returns safe transfer objects. This reduces the chance that two routes implement ownership differently or that a full database row crosses a client boundary.

For sensitive values, combine server-only modules with the framework's data-security tools where appropriate. The goal is defense in depth: prevent accidental client imports, minimize serialization and still authorize at the point of access. A hidden button is never an authorization control.

Harden Server Actions as public mutation endpoints

Treat every action argument as untrusted. Parse it, load the current user, verify object ownership and re-check state immediately before mutation. Next.js compares action origins and supports allowedOrigins for proxy arrangements, but origin checking is CSRF defense—not user authorization. Keep the allowlist narrow and test the deployed proxy headers.

Rate-limit expensive or abuse-prone actions at a stable identity boundary. Make mutations idempotent where retries are plausible, avoid returning internal exception detail and log denied administrative actions with enough context for investigation but no secrets.

Deploy a CSP you can maintain

A static default-src 'self' policy is a useful start but rarely sufficient for a real application. Inventory scripts, styles, fonts, images, frames and connection targets. Prefer nonces or hashes to broad unsafe-inline; Next.js documents a nonce-based pattern for dynamic rendering. Start with Content-Security-Policy-Report-Only, collect violations, remove accidental dependencies, then enforce. Test every important route because a policy that breaks login will be disabled under pressure.

Add regression tests at the boundaries

Your security suite should request each Route Handler without a session, invoke each sensitive Server Action as the wrong owner, inspect a production response for headers, and build the client bundle before scanning it for known secret prefixes. Add an open-redirect table and a sanitized rich-text fixture. These checks verify behavior instead of assuming configuration was loaded.

Framework updates can change defaults, so link controls to the current official documentation and review them during upgrades. Use the Vercel deployment checklist for platform-specific gaps and secure Next.js with Supabase for RLS-backed authorization.

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.

Frequently asked questions

Are Next.js Server Components private by default?

They execute on the server, but their data access still needs authentication, authorization and output minimization. Props passed to Client Components cross a serialization boundary and should contain only safe fields.

Do Server Actions automatically authorize users?

No. Next.js provides transport protections such as origin checks, but each action must validate input, establish identity and authorize the exact object and operation before mutating data.

Should Next.js CSP use unsafe-inline?

Avoid broad unsafe-inline where practical. Next.js documents nonce-based CSP for dynamic rendering; deploy in report-only mode first, inventory required sources and test critical routes before enforcement.

Where should authorization logic live?

Centralize it near the data access and mutation. Middleware can improve navigation and coarse gating, but Route Handlers, Server Actions and database queries must enforce the final decision.

Sources and verification notes

Product capabilities are vendor-attributed and source-dated. Technical guidance uses primary documentation or vendor-neutral standards.

  1. Next.js data security guidePrimary framework guidance for data access, authorization and safe transfer patterns.
  2. Next.js Content Security Policy guidePrimary source for nonce-based and deployment-aware CSP patterns.
  3. Next.js headers configurationPrimary source for configuring response headers by route.
  4. Next.js Server Actions configurationPrimary source for action configuration including allowed origins.

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