open atlas
↑ Back to track
Next.js, zero to senior NEXT · 04 · 04

CSRF and action security: every server action is a public POST endpoint

Every exported server action is a public POST endpoint. Next compares Origin to Host — that stops cross-site forgery and nothing else, and route handlers get no such check. Authentication, per-row authorization, zod validation, and rate limiting belong inside every action.

NEXT Senior ◷ 18 min
Level
FoundationsJuniorMiddleSenior

Day two of the pentest, the tester stops clicking the UI and opens the network tab. Every server action in the app is a POST to the current URL with a Next-Action header carrying an opaque id — and the ids are right there in the public JS bundle. First attempt: a forged form on an external host posting to the app. Blocked — Next compared the Origin header to the Host and rejected the mismatch; the framework’s CSRF check earns its keep. Second attempt: replay the “rename project” action from the tester’s own logged-in session, with the projectId argument swapped to another tenant’s project. It lands. The action authenticated the caller and never asked whether this caller may touch this row — unit 02 called this the IDOR pattern, and it survives every framework protection because it is not a forgery: it is a perfectly authentic request doing something it should never have been allowed to do. The report’s summary line writes itself: “The framework stopped the attacks aimed at the framework. Nothing stopped the attacks aimed at the application.”

An action is a public POST route with a hash for a name

Before you write your next server action, consider what an attacker who already has a valid session can do with it — and notice that the framework does nothing to limit that. Everything from here follows from that gap.

Strip the RPC illusion: when you export a function from a 'use server' file, Next.js creates an HTTP endpoint. The client invokes it by POSTing to the page URL with a Next-Action header containing the action’s id; arguments travel serialized in the body. Three properties follow, each with security weight. Reachability is not gated by UI: the action is invocable whether or not any button renders it — “we removed the delete button for non-admins” changes pixels, not the endpoint. Every exported action from every 'use server' file is an endpoint, including the one whose UI you deleted last sprint; Next mitigates with dead-code elimination of unreferenced ids, but an action still referenced anywhere stays live, so treat exported as public. Action ids are not secrets — they are non-deterministic per build, but they sit in the JS bundle every visitor downloads. Security through unguessable ids lasts exactly until someone opens devtools.

One nuance works in your favor: closed-over values are encrypted. If an action defined inside a component closes over a variable from the render scope, Next encrypts those values with a per-build key before they round-trip through the client. But arguments — everything the client passes when calling the action — are attacker-controlled plaintext, exactly like a request body, because that is what they are.

The CSRF protection you get, and its exact edges

It helps to be precise about what CSRF (Cross-Site Request Forgery — a forged request from a different origin riding your cookies) actually is, because knowing its exact definition reveals what it does not cover — and that gap is where real incidents happen.

Classic CSRF: evil.com auto-submits a form to your origin from a victim’s browser, riding the victim’s cookies. Next.js layers two defenses for actions. First, actions only accept POST — and session cookies set with SameSite=Lax (lesson 01) are not sent on cross-site POSTs at all, so on modern browsers the forged request usually arrives anonymous. Second, Next compares the Origin header to the Host (X-Forwarded-Host) header on every action invocation and rejects mismatches. This covers gaps the cookie layer leaves — older SameSite=None cookies, subtle same-site cases: notably, a forged POST from a sibling subdomain (status.example.comapp.example.com) is same-site, so SameSite=Lax cookies ride along — but Origin still differs from Host, and the action check rejects it.

Now the edges, because they are where incidents live. Edge one: the check protects actions only. Your route handlers — app/api/*/route.ts — get no automatic CSRF protection whatsoever; a POST route handler relying on cookie auth with SameSite=None is plainly forgeable, and even with Lax you should treat handler CSRF as your own problem. Edge two: proxies can break or loosen it. If your app sits behind a reverse proxy that rewrites Host, legitimate requests fail the comparison — and the documented escape hatch, serverActions.allowedOrigins, is a list that pentests find stuffed with wildcards added during some 3 a.m. debugging session. Every entry is an origin you have declared trusted to forge requests. Edge three — the one from the Hook: CSRF protection is about where the request came from, not what it is allowed to do. An authenticated user attacking your authorization model sails through every Origin check, because nothing about their request is forged.

Quiz

An app uses server actions for in-app mutations and a route handler POST /api/export for a legacy integration, with cookie auth. Which CSRF surface does the framework actually cover?

Authorize inside, validate at the boundary, rate-limit the mutation

Everything the framework cannot know — who may do what to which row, what a valid payload looks like, how often a mutation is plausible — must live inside the action, every action, no exceptions for “internal” ones. The discipline compresses to a pipeline, and unit 02’s server-actions lesson showed its skeleton; this is the full armored version:

'use server';
import { z } from 'zod';
import { notFound } from 'next/navigation';
import { revalidatePath } from 'next/cache';
import { verifySession } from '~/lib/dal';
import { ratelimit } from '~/lib/ratelimit';
import { db } from '~/lib/db';

const RenameInput = z.object({
  projectId: z.string().uuid(),
  name: z.string().min(1).max(120),
});

export async function renameProject(raw: unknown) {
  const session = await verifySession();                     // 1. authenticate
  const { success } = await ratelimit.limit(session.userId); // 2. e.g. 20 writes / 10 s
  if (!success) throw new Error('Too many requests');
  const input = RenameInput.parse(raw);                      // 3. the wire is untyped
  const project = await db.project.findUnique({ where: { id: input.projectId } });
  if (project?.ownerId !== session.userId) notFound();       // 4. authorize THIS row
  await db.project.update({ where: { id: input.projectId }, data: { name: input.name } });
  revalidatePath(`/projects/${input.projectId}`);            // 5. tell the caches
}

Step 3 deserves its own sentence: the raw: unknown type is the honest one. Your TypeScript signature is compile-time fiction — at runtime the body is whatever bytes the caller sent, and RenameInput.parse is the moment fiction becomes checked fact. Validation also closes mass assignment: an action that spreads ...data into an update lets a crafted payload set role: 'admin' unless the schema allowlists fields. Step 4 is the IDOR check from the Hook — ownership verified against the actual row, not against which buttons rendered. Step 2 acknowledges that mutations are the expensive, abusable surface: a login action without rate limiting is a free credential-stuffing oracle (typical policy: 5 attempts per 15 minutes per IP+identifier), and a costly mutation without limits is a denial-of-wallet invitation; sliding-window limits in KV cost ~1 ms and run fine in actions. The same shared verifySession/wrapper pattern from the DAL lesson applies: encode the pipeline once, make the safe path the lazy path, and let reviewers hunt only for mutations that bypass the wrapper.

Why this works

Why does the framework not authorize for you? Because authorization is a domain statement, not a transport property. Next.js can see headers, so it checks headers — Origin against Host. It cannot know that projects have owners, that owners may rename but viewers may not, or that role is not a client-settable field. Every framework draws this same line; what changes with server actions is the temptation to forget it, because calling await renameProject(id) feels like a local function call, and ten years of instincts say local calls are trusted. The wire disagrees: it is a POST from a hostile network, wearing your function’s name.

Quiz

A deleteDocument(docId) action calls verifySession(), then db.document.delete({ where: { id: docId } }). The delete button only renders for document owners. A pentester deletes another user's document. How?

Recall before you leave
  1. 01
    What CSRF protection do server actions get, and what are its three edges?
  2. 02
    Recite the five-step pipeline every mutating action needs, and the attack each step kills.
Recap

Exporting a function from a ‘use server’ file creates an HTTP endpoint: the client POSTs to the page URL with a Next-Action id from the public bundle, and the arguments arrive as attacker-controlled plaintext — only closed-over values get per-build encryption. Reachability has nothing to do with UI: removed buttons, admin-only rendering, and unused-but-exported actions are all live endpoints, so exported means public. The framework’s CSRF story for actions is two genuine layers — SameSite=Lax cookies that stay home on cross-site POSTs, and an Origin-versus-Host comparison on every invocation that even catches sibling-subdomain forgeries where Lax cookies still ride — and three exact edges: route handlers get none of it and bring their own CSRF defense; Host-rewriting proxies break the comparison, and every serverActions.allowedOrigins entry is an origin you have declared trusted to forge; and the check authenticates provenance, not permission — an authenticated attacker is invisible to it. That last edge is the pentest finding that keeps recurring: IDOR, a valid session pointing a real action at someone else’s row, which unit 02 introduced and this unit armors against. The armor is a five-step pipeline inside every mutating action: verifySession to authenticate; a rate limit because login actions without one are credential-stuffing oracles and costly mutations are denial-of-wallet invitations; zod-parse of a payload honestly typed as unknown, which also closes mass assignment by allowlisting fields; per-row ownership comparison before the write, because that is the only line that stops IDOR; then revalidate. Now when you write a server action that accepts an id from the caller, the first thing you should picture is a pentester swapping that id for someone else’s — and the ownership check is the only thing between them and the data.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 6 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

Apply this

Put this lesson to work on a real build.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.