May 22, 2026
Next.js CSRF Protection in App Router for Safer Forms and Mutations
A practical guide to Next.js CSRF protection in App Router apps covering Server Actions, route handlers, same-origin checks, CSRF tokens, cookies, and testing.
8 min read
Next.js CSRF Protection in App Router for Safer Forms and Mutations
If you are searching for Next.js CSRF protection App Router, you are probably using cookie-based sessions with Server Actions, route handlers, or traditional form submissions. That is exactly where CSRF risk appears: the browser automatically sends cookies, and an attacker may try to trigger a state-changing request from another site.
This guide shows a practical pattern for Server Actions CSRF protection and Next.js CSRF token route handlers. You will combine same-origin checks, secure cookie settings, per-form tokens, and boring tests. It pairs naturally with Next.js Server Actions Security Best Practices for App Router Apps, Secure API Route Patterns in Next.js for Safer App Router Backends, Next.js Zod Validation in App Router for Safer Server Actions, and Next.js Rate Limiting in App Router for Safer Route Handlers.
CSRF Is About Browser-Attached Credentials
Cross-site request forgery happens when a hostile site causes a user's browser to send a request to your app with credentials the browser already has. If your app trusts the session cookie and the request changes state, the attacker may not need to read the response. They only need the mutation to happen.
CSRF is most relevant when you use:
- cookie-based sessions
- unsafe methods such as
POST,PUT,PATCH, orDELETE - forms that mutate account or billing state
- Server Actions called from authenticated pages
- route handlers that accept browser traffic
- admin dashboards protected only by a session cookie
It is less relevant for APIs that require an Authorization header the attacker cannot make the browser attach automatically. Even then, route handlers often evolve. A public JSON endpoint today may become a cookie-authenticated endpoint later, so it is worth making the boundary explicit.
Start With Cookie Defaults, But Do Not Stop There
Good cookie settings reduce CSRF exposure. They do not replace CSRF checks for sensitive mutations.
For session cookies, use HttpOnly, Secure in production, and a restrictive SameSite value. SameSite=Lax is a reasonable default for many product apps because it allows normal top-level navigation while blocking many cross-site subrequests. SameSite=Strict is stronger but can be awkward around login redirects, preview links, and cross-domain flows.
import { cookies } from "next/headers";
export function setSessionCookie(value: string) {
cookies().set("session", value, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 24 * 7,
});
}
These flags are the baseline. A browser bug, legacy client, relaxed cookie setting, cross-subdomain deployment, or future refactor can still put a mutation at risk. Treat cookie flags as one layer, then add request-level checks around high-value writes.
Add a Same-Origin Check for Mutations
The first low-friction control is checking that the request came from your own origin. For browser form posts and fetch calls, the Origin header is usually present on unsafe methods. If the origin exists and does not match your app origin, reject the request before parsing the body or running business logic.
import { headers } from "next/headers";
export function assertSameOrigin() {
const headerList = headers();
const origin = headerList.get("origin");
const host = headerList.get("x-forwarded-host") ?? headerList.get("host");
const proto = headerList.get("x-forwarded-proto") ?? "https";
if (!origin || !host) {
return;
}
const expectedOrigin = `${proto}://${host}`;
if (new URL(origin).origin !== expectedOrigin) {
throw new Error("Invalid request origin.");
}
}
Use this helper at the top of sensitive Server Actions and route handlers. In production, you may prefer comparing against a configured APP_ORIGIN instead of building the origin from forwarded headers. That is often clearer when the app runs behind a proxy, on preview deployments, or across multiple domains.
Same-origin checks are not a complete CSRF strategy because some legitimate requests may lack an Origin header, and proxy configuration can be inconsistent. They are still valuable because they block a large class of cross-site form posts with very little application code.
Add Next.js CSRF Token Route Handlers
For the strongest browser-facing pattern, issue a random token on the server, store it in an HttpOnly cookie, render the same value into the form or client payload, and compare both values when the mutation arrives. A hostile site can make the browser attach the cookie, but it cannot read your page to discover the hidden token value.
import { randomBytes, timingSafeEqual } from "crypto";
import { cookies } from "next/headers";
const CSRF_COOKIE = "csrf-token";
export function createCsrfToken() {
const token = randomBytes(32).toString("base64url");
cookies().set(CSRF_COOKIE, token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
});
return token;
}
export function verifyCsrfToken(submittedToken: string | null) {
const cookieToken = cookies().get(CSRF_COOKIE)?.value;
if (!cookieToken || !submittedToken) {
return false;
}
const cookieBuffer = Buffer.from(cookieToken);
const submittedBuffer = Buffer.from(submittedToken);
if (cookieBuffer.length !== submittedBuffer.length) {
return false;
}
return timingSafeEqual(cookieBuffer, submittedBuffer);
}
Then require the token in a route handler before validation and side effects:
import { assertSameOrigin } from "@/lib/security/origin";
import { verifyCsrfToken } from "@/lib/security/csrf";
export async function POST(request: Request) {
assertSameOrigin();
const token = request.headers.get("x-csrf-token");
if (!verifyCsrfToken(token)) {
return Response.json({ error: "Invalid CSRF token." }, { status: 403 });
}
const payload = await request.json();
// Validate with Zod, authorize ownership, then mutate.
return Response.json({ ok: true });
}
This is the practical shape of Next.js CSRF token route handlers: reject bad origin, compare a server-issued token, validate the payload, authorize the resource, and only then perform the mutation.
Use Server Actions CSRF Protection in Forms
Server Actions make forms feel direct, but they still execute state-changing server code. If the action depends on a cookie session, protect it like any other mutation.
Render a token into the form from a Server Component:
import { createCsrfToken } from "@/lib/security/csrf";
import { updateProfileAction } from "./actions";
export function ProfileForm() {
const csrfToken = createCsrfToken();
return (
<form action={updateProfileAction}>
<input type="hidden" name="csrfToken" value={csrfToken} />
<input name="displayName" minLength={2} maxLength={80} />
<button type="submit">Save profile</button>
</form>
);
}
Then check the token at the top of the action:
"use server";
import { assertSameOrigin } from "@/lib/security/origin";
import { verifyCsrfToken } from "@/lib/security/csrf";
export async function updateProfileAction(formData: FormData) {
assertSameOrigin();
const csrfToken = String(formData.get("csrfToken") ?? "");
if (!verifyCsrfToken(csrfToken)) {
return { formError: "Your session expired. Please refresh and try again." };
}
const displayName = String(formData.get("displayName") ?? "").trim();
// Validate, authorize, update the profile, and revalidate cached UI.
return {};
}
That gives you Server Actions CSRF protection without turning the form into a client-only workflow. The server owns token creation and verification, while the user still gets normal progressive form behavior.
Put CSRF in the Route Security Order
CSRF checks should happen early, but not in isolation. A useful order for cookie-authenticated mutations is:
- reject invalid origin
- verify CSRF token
- authenticate the session
- rate limit the user, tenant, or IP
- validate input with a schema
- authorize the resource
- perform the side effect
- revalidate cache or redirect
This order blocks cross-site mutations before expensive work starts. It also keeps validation and authorization in place for same-origin requests. CSRF protection proves the request came through a page or client flow you issued. It does not prove the user may update a specific record, upload a certain file, or change a billing setting.
That distinction matters during reviews. Do not let a token check replace the ownership checks from your secure API route pattern, the payload boundaries from your Zod validation layer, or the abuse budgets from your rate limiter.
Test the Rejection Path
CSRF bugs often appear because the happy path gets tested and the rejection path does not. Add at least one test or integration check that submits a mutation without a token and expects 403 or a safe form error.
import { describe, expect, it } from "vitest";
import { verifyCsrfToken } from "@/lib/security/csrf";
describe("verifyCsrfToken", () => {
it("rejects a missing token", () => {
expect(verifyCsrfToken(null)).toBe(false);
});
});
For route handlers, test missing token, wrong token, and wrong origin. For Server Actions, test the form state returned when the token is missing. You do not need a huge test suite to make CSRF visible; you need the failure mode represented so a future refactor cannot silently remove it.
Avoid Common CSRF Mistakes
The most common mistake is assuming that App Router or Server Actions automatically remove CSRF risk. They improve ergonomics, but they do not change the browser rule that cookies are attached automatically.
Other mistakes include:
- protecting JSON route handlers but forgetting Server Actions
- relying only on client-side checks
- using a static token shared across sessions
- accepting tokens after logout or session rotation
- skipping origin checks on admin mutations
- treating CSRF as a replacement for authorization
- returning detailed security errors to public clients
The fix is a small shared helper and a route template that teams can repeat. Security should be obvious in the handler, not hidden in scattered one-off checks.
Final Takeaway
Next.js CSRF protection App Router work is about defending cookie-authenticated mutations. Start with strong cookie settings, reject invalid origins, use per-session CSRF tokens, and check them before validation, authorization, or side effects.
For Server Actions CSRF protection, render a server-created hidden token and verify it inside the action. For Next.js CSRF token route handlers, require a token header or form field and compare it with the server-owned cookie. Combined with validation, rate limiting, secure API patterns, and safe errors, CSRF protection becomes a small repeatable part of the App Router mutation boundary.