- Next.js
- Security
- Checklist
Next.js App Router Security Checklist
A practical security checklist for Next.js App Router apps: server actions, route handlers, middleware, env vars, CSRF, headers, webhooks and per-user caching.
· 8 min read · Lina Source LLC
The App Router moved a lot of code from the browser back to the server. That is mostly a security win: queries, secrets and business logic now run somewhere users cannot read. It also blurred the line between what is a public endpoint and what only looks like a function call. Most serious bugs in Next.js apps come from that blur.
This checklist covers the issues we see most often in App Router codebases, in the order worth checking them. None of it is exotic; each item is a place where the framework's convenience hides a trust boundary.
1. Server actions are public endpoints
A function marked 'use server' compiles to an HTTP endpoint. Anyone who can load your site can find the action ID and call it with arbitrary arguments, whether or not your UI ever renders the button that uses it. Hiding a form from non-admins does not protect the action behind it.
Every action needs the same three steps as any API handler: authenticate the caller, validate the input, and authorize the specific record being touched. Put these checks inside the action body itself. A check in the page component that renders the form runs when the page loads, not when the action is called, so it protects nothing.
'use server';
import { z } from 'zod';
import { revalidatePath } from 'next/cache';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
const Input = z.object({
projectId: z.string().uuid(),
name: z.string().trim().min(1).max(100),
});
export async function renameProject(formData: FormData) {
const session = await auth();
if (!session?.user) throw new Error('Unauthorized');
const { projectId, name } = Input.parse({
projectId: formData.get('projectId'),
name: formData.get('name'),
});
// The ownership check is part of the write, not a separate lookup
const result = await db.project.updateMany({
where: { id: projectId, ownerId: session.user.id },
data: { name },
});
if (result.count === 0) throw new Error('Not found');
revalidatePath('/projects');
}Watch for helper files that export many actions. Every exported function in a 'use server' module is callable, including the one someone added for an internal script and forgot about.
2. Route handlers need the same checks
Route handlers in app/api are more obviously endpoints, but they share the same failure mode: a dynamic segment is used to load a record without checking who owns it. In recent Next.js versions params is a Promise; await it, validate it, and scope the query to the caller.
// app/api/documents/[id]/route.ts
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
export async function GET(
_req: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const session = await auth();
if (!session?.user) {
return Response.json({ error: 'unauthorized' }, { status: 401 });
}
const { id } = await params;
const doc = await db.document.findFirst({
where: { id, ownerId: session.user.id },
});
if (!doc) return Response.json({ error: 'not_found' }, { status: 404 });
return Response.json(doc, {
headers: { 'Cache-Control': 'private, no-store' },
});
}Return 404 rather than 403 for records the caller does not own, so the endpoint does not confirm which IDs exist. Never trust params, searchParams, headers or cookies for authorization decisions; they are all attacker-controlled input.
3. Middleware is not an auth boundary by itself
Middleware (renamed proxy in Next.js 16) is useful for redirecting signed-out users and setting headers. It should not be the only place authorization happens. Matchers are easy to get wrong, new routes get added outside them, and server actions post to the page path, which may not match the pattern you had in mind. In 2025, CVE-2025-29927 showed that a crafted internal header could make some Next.js versions skip middleware entirely.
Treat middleware as a convenience layer. The real check belongs next to the data: in each action and handler, or better, in a data access layer that every server-side read goes through. A data access layer is a single server-only module that exposes functions like getProjectForUser(projectId) and performs the session check and ownership filter inside. Pages, actions and route handlers call it instead of the database client directly, so a new route cannot forget the check: there is no unchecked path to forget.
4. Keep server code on the server
Any environment variable prefixed NEXT_PUBLIC_ is inlined into the JavaScript bundle at build time and is visible to every visitor. That is correct for a publishable Stripe key or an analytics ID, and a leak for anything else. Search the codebase for NEXT_PUBLIC_ and check that each one would be safe on a billboard. The reverse mistake happens too: a variable without the prefix is read in a client component, comes back undefined in the browser, and someone renames it to NEXT_PUBLIC_ to make the error go away. If a value is needed in the browser, first ask whether the browser should have it at all.
// lib/dal.ts
import 'server-only';
import { cache } from 'react';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
export const getCurrentUser = cache(async () => {
const session = await auth();
if (!session?.user) return null;
// Return only the fields the UI needs, never the whole row
return db.user.findUnique({
where: { id: session.user.id },
select: { id: true, name: true, plan: true },
});
});The server-only package makes the build fail if a client component imports the module, which protects database clients and secret-reading helpers from ending up in the browser. Also watch what server components pass as props to client components: everything passed across that boundary is serialized into the page, so passing a full user row ships its password hash and internal flags to the browser.
5. CSRF: know what the framework covers
Server actions only accept POST, and Next.js compares the Origin header with the host before running them. If you deploy behind a proxy or on multiple domains, configure serverActions.allowedOrigins deliberately rather than widening it until errors go away.
Route handlers get no such protection. If a POST, PUT or DELETE handler authenticates with cookies, a form on another site can still submit to it. Checking the content type is not enough on its own: an HTML form can send urlencoded, multipart and text/plain bodies without a preflight, and a handler that parses the body leniently will accept them. Set session cookies with SameSite=Lax or Strict, never perform state changes on GET, and check the Origin header on cookie-authenticated mutations. Handlers that only accept a bearer token in the Authorization header are not exposed to classic CSRF.
6. Security headers and CSP
Next.js sends very few security headers by default. Add them in next.config through the headers() function, or in middleware when you need a per-request nonce for a strict Content-Security-Policy.
- Content-Security-Policy, ideally nonce-based, with object-src 'none' and base-uri 'self'.
- Strict-Transport-Security with a long max-age once HTTPS is solid everywhere.
- X-Content-Type-Options: nosniff.
- Referrer-Policy: strict-origin-when-cross-origin.
- frame-ancestors in the CSP, or X-Frame-Options, to prevent clickjacking.
- poweredByHeader: false in next.config, to drop the X-Powered-By header.
7. Rate limiting
There is no built-in rate limiter. Sign-in, sign-up, password reset, OTP verification and anything that costs money per call (email, SMS, AI requests) need limits per user and per IP. In-memory counters do not work on serverless or multi-instance deployments; use a shared store such as Redis or your database. Apply limits inside the action or handler, where the authenticated user is known, not only in middleware. Key limits on something an attacker cannot rotate cheaply: the account for authenticated routes, and the target email or phone number for reset and OTP flows, in addition to the IP. Return 429 with a Retry-After header so legitimate clients back off correctly.
8. Verify webhooks against the raw body
Webhook signatures are computed over the exact bytes the provider sent. Parsing the body as JSON and re-serializing it changes those bytes and breaks verification, which tempts people to skip it. In a route handler, read the body with req.text() and verify before doing anything else.
// app/api/webhooks/stripe/route.ts
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
const body = await req.text();
const signature = req.headers.get('stripe-signature');
if (!signature) return new Response('Missing signature', { status: 400 });
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!,
);
} catch {
return new Response('Invalid signature', { status: 400 });
}
// Handlers must be idempotent: providers retry and may deliver twice
await handleStripeEvent(event);
return new Response('ok');
}Store processed event IDs with a unique constraint so a retried or replayed delivery does not grant a subscription twice, and handle events arriving out of order, since providers do not guarantee delivery order.
9. Do not cache per-user data globally
The App Router caches aggressively, and the defaults have changed between versions. A function wrapped in unstable_cache or 'use cache' that returns data for 'the current user' but does not include the user ID in its cache key will serve one user's data to the next. The same applies to pages rendered statically that should have been dynamic, and to CDN caching of API responses. Test it directly: sign in as two different users in two browsers and load the same pages. Anything that shows the first user's data to the second is a cache bug, and usually a serious one.
- Pass the user or tenant ID explicitly into cached functions so it becomes part of the key.
- Do not read cookies or headers inside cached functions; read them outside and pass the values in.
- Send Cache-Control: private, no-store on responses that contain per-user data.
- Check the build output: routes you expect to be dynamic should not be listed as static.
Running the checklist
Go through the list per route, not per file: for each action and handler, who can call it, what input it trusts, what it caches and what it returns. CodeAuditAgent can do a first pass on a public GitHub repository or a pasted snippet, reporting each finding with severity, CWE, the quoted line and a suggested patch; the design questions, like which routes should be public at all, still need your team's judgment.