Let me state the conclusion first. Next.js's (App Router) Server Actions have, by default, practical CSRF resistance: "can only be invoked via POST," "confirm the match of the Origin header and the Host header," and "use a hard-to-guess encrypted action ID." This is indeed useful and, even without any configuration, rejects the majority of common CSRF. But — it's not all-purpose. The default protection works only on the single route of "Server Actions," and a route.ts Route Handler, or a self-made API that authenticates with a cookie alone, isn't protected from CSRF unless you protect it explicitly.
This article accurately redraws this boundary. It states "the scope Next.js protects by default" faithful to the scope the official documentation shows, and on that basis shows, with vulnerable code and fixed code, the defenses the developer should add themselves — SameSite cookies, Origin/Host verification on state-changing routes, allowedOrigins for reverse-proxy environments, and a double-submit token if needed. This is neither a story of "Next.js's CSRF countermeasure is weak" nor "you don't need to do anything anymore." It's a map for accurately knowing what's protected by default and plugging only the remaining holes at minimal cost.
1. What CSRF is in the first place — "an unintended state change with the victim's cookie"
First, let's pin down the terms accurately. CSRF (Cross-Site Request Forgery) is an attack that makes a logged-in victim's browser send a request the person doesn't intend, from another site the attacker prepared. It's a classic threat class long treated even in OWASP Top 10 (OWASP Top 10).
The mechanism is anticlimactically simple.
1. The victim is logged in to bank.example (the session cookie is saved in the browser)
2. The victim opens the attacker's trap site evil.example
3. A <form action="https://bank.example/transfer" method="POST"> planted on the trap site is auto-submitted
4. The browser automatically attaches the saved session cookie to the request bound for bank.example
5. The server executes the transfer processing as "a legitimate user's request"
The essence of CSRF is two points. First, the attacker can't read the contents of the cookie. They just abuse the browser's nature of "automatically sending the cookie for the same domain." Second, that's exactly why what's targeted is a state change (write). Since the read result doesn't reach the attacker, CSRF's targets are limited to "operations with side effects" like transfers, password changes, and deletions.
From here, two design principles are derived. (a) Don't perform state changes with a safe method (GET), and (b) confirm that a state-changing request "really originates from your own site." Next.js Server Actions' default protection (partially) stands in for exactly these two. In the next section, let's accurately see "the scope of that standing-in" exactly as the official describes.
2. The protection Next.js Server Actions have by default — within the scope the official shows
Since this is the most misunderstood part of this article, I'll organize it strictly along the scope the Next.js official documentation states (Next.js docs). It's important not to expand "so it's safe" by speculation.
What the official lists as "Server Actions' built-in security features" are the following three.
| Default protection | The official explanation (gist) | How it works against CSRF |
|---|---|---|
| POST-method only | Server Actions internally use POST and can only be invoked with this HTTP method | Structurally prevents GET-based CSRF like <img>/<a>/GET forms |
| Match check of Origin and Host | Compares the Origin header with the Host header (or X-Forwarded-Host), and aborts the request if they don't match | Rejects sending from a trap site of a different origin |
| Encrypted action ID | Generates an encrypted, non-deterministic ID for the client to reference/invoke the action. Recomputed between builds (cached up to 14 days) | Makes the endpoint's path hard to guess |
The official, in addition to this, clearly states "because Server Actions can be invoked from a <form> they can be exposed to CSRF, but being POST-only, and SameSite cookies being the default in modern browsers, prevents most CSRF vulnerabilities." Furthermore, as additional protection, there's the above Origin/Host collation, and it states "if they don't match, the request is aborted. That is, Server Actions can only be invoked from the same host as the page that hosts them."
This is "the scope the official shows." For accuracy, three supplements.
-
The encrypted action ID is a "wall of secrecy," not a "wall of CSRF." This is a mechanism to make the action's endpoint hard to guess and to remove unused actions from the client bundle by dead-code elimination. The protagonists that stop CSRF itself are, after all, POST-only and Origin/Host collation. Don't overrate the ID encryption as a "CSRF countermeasure."
-
Closure encryption is a separate story. When a Server Action defined inside a component takes in an outer variable (closure), Next.js encrypts that value and sends it to the client. This is prevention of the leakage of sensitive values, and a separate layer from CSRF. The official also warns "don't rely on encryption alone to protect sensitive values."
-
This isn't a substitute for authorization. The official places the most important proviso — "this improvement lowers the risk when an authentication layer is missing. But still treat Server Actions as reachable by a direct POST request, and verify authentication and authorization inside each action." In other words, a Server Action isn't "safe because it's not shown in the UI"; on the premise that it can be hit directly from external, you must always perform identity confirmation and resource-ownership confirmation inside.
The honest summary. Next.js's default protection is "solid." POST-only + Origin/Host collation rejects the majority of real CSRF out of the box. But as the official itself says, this doesn't stand in for authentication/authorization and works only on the single route of Server Actions. I'll concretize "the lacking parts" from the next section on.
3. The remaining holes — four areas the default protection doesn't reach
Even if the default protection is excellent, there are areas it structurally doesn't cover. Believing "Next.js protects me" without grasping this is the most dangerous misunderstanding.
3-1. Route Handlers (route.ts) aren't automatically protected
This is the biggest pitfall. The POST-only and Origin/Host collation of the previous section are a mechanism of "Server Actions" and aren't automatically applied to app/api/**/route.ts Route Handlers. A Route Handler is a raw HTTP endpoint where you can define GET, POST, and DELETE yourself, and Next.js doesn't put in origin checks for you. The reason the official, from an audit viewpoint, names "proxy.ts and route.ts are powerful. Audit them intensively with conventional methods" is precisely this degree of freedom.
// app/api/account/route.ts — 脆弱:CookieだけでPOSTを認証し、出所を確認していない
import { cookies } from "next/headers";
export async function POST(req: Request) {
const session = (await cookies()).get("session")?.value;
if (!session) return new Response("unauthorized", { status: 401 });
const { email } = await req.json();
await updateAccountEmail(session, email); // ← evil.example からのCSRFでも実行されうる
return Response.json({ ok: true });
}
This route, unlike Server Actions, doesn't have Origin/Host collation. If a trap site fires fetch("https://yourapp.com/api/account", { method: "POST", credentials: "include" }) from the victim's browser, the session cookie is automatically attached, and there's room for the email change to go through (CORS doesn't stop the sending of a simple request itself. What it stops is reading the response, and the side effect happens first on the server side).
3-2. Being cookie-based authentication is itself a precondition for CSRF
The root of CSRF holding is that "you authenticate with only the credential the browser automatically attaches (= the cookie)." Conversely, if you authenticate with "a credential the browser doesn't automatically attach," like a Bearer token in the Authorization header or a synchronizer token in the request body, the main cause of CSRF disappears. That's because the trap site can't put the victim's token in a header. Since Server Actions are form-submission = cookie-premised, the default protection looks at the origin. If your self-made API is cookie-authenticated, you need to add the same defense yourself.
3-3. Cases where the premise of cross-subdomain / SameSite collapses
The SameSite cookie default the official relies on is powerful but not all-purpose. SameSite=Lax (the default of many browsers) is sent on top-level navigation GET, so if you perform state changes with GET, they pass straight through. Also, if you share cookies between subdomains with the Domain attribute like .example.com, a hijacked or low-trust subdomain (like blog.example.com) is treated as the same site, and Origin/Host can become an attack surface within the range of "the same registrable domain." Since SameSite's behavior differs per value, confirm it accurately at MDN: SameSite cookies.
3-4. Routes where the Origin header isn't reliable, and old patterns
The default Origin/Host collation stands on the premise that the browser sends Origin correctly. Modern browsers send Origin almost always on POST, but the premise collapses with (a) non-browser clients (scripts, old libraries), (b) very old browsers, and (c) the legacy pattern of changing state with GET without using <form action>. Furthermore, if the Host differs from the production domain in a reverse-proxy or multi-layer configuration, the collation erroneously fails (or, depending on the setting, erroneously passes). The latter is the area where the official guides the allowedOrigins setting (covered in 5-3).
| Remaining hole | Why the default protection doesn't work | Defense to add yourself (described later) |
|---|---|---|
| Route Handler's GET/POST | The Server Actions mechanism isn't applied to route.ts | Self-implement Origin/Host verification on state change (5-2) |
| Cookie-only authentication | The very precondition of CSRF | Non-cookie credentials, or a synchronizer token (5-4) |
| Subdomain sharing / GET change | SameSite=Lax lets top-level GET through | Unify state changes to POST + consider SameSite=Strict (5-1) |
| Host mismatch by proxy | Origin and Host diverge | serverActions.allowedOrigins setting (5-3) |
4. How the attack holds — running one vulnerable implementation through
Let me drop the abstract into the concrete. The following is the typical case of "even though you use Server Actions, you've created a route where origin confirmation doesn't work." The Server Action itself is protected by default, but if you let the state change escape to the Route Handler called from it, it goes outside the protection.
// app/api/newsletter/unsubscribe/route.ts — 脆弱
// GETで状態変更している=最悪のCSRF面。<img src> 一発で発火する
export async function GET(req: Request) {
const url = new URL(req.url);
const id = url.searchParams.get("id"); // ← クライアントが自由に指定
await unsubscribe(id!); // ← 副作用をGETで実行
return new Response("unsubscribed");
}
<!-- evil.example に仕込むだけ。被害者がページを開いた瞬間に発火する -->
<img src="https://yourapp.com/api/newsletter/unsubscribe?id=42" />
The reasons this attack holds overlap in three — (1) it performs the state change with GET (it doesn't receive the benefit of POST-only in the first place), (2) being a Route Handler, there's no Origin/Host collation, and (3) since it confirms identity with a cookie, the victim's browser automatically attaches the credential. Server Actions' default protection doesn't stand in for any of these. The moment you move the route to a Route Handler, the defense becomes entirely your responsibility.
5. The defenses to add — plug the holes at minimal cost
Here's the main topic. On the foundation of the default protection, plug only the remaining holes. Rather than blindly putting everything in, the cost-efficient way is to discern which holes your app has with the table in section 3 before adding.
5-1. Unify state changes to POST or above, and attach SameSite to cookies
First, don't provide operations with side effects via GET. With just this, the class of attack in chapter 4 disappears. On top of that, make SameSite explicit on the authentication cookie. Rather than riding on "the browser's default" that Server Actions relies on, explicitly declaring it in your own cookie is robust.
// 認証Cookieは SameSite を明示する。状態変更主体のアプリは Strict も検討
import { cookies } from "next/headers";
(await cookies()).set("session", token, {
httpOnly: true, // JSから読めない(XSS経由の盗難を緩和)
secure: true, // HTTPSのみ
sameSite: "lax", // クロスサイトの自動送信を抑制。外部遷移直後もログイン維持したいなら lax
path: "/",
});
The choice of Lax and Strict is a trade-off with UX. With Strict, the cookie isn't sent right after returning from an external link, so it looks "not logged in"; for authentication, Lax is the standard. Conversely, for an admin panel where the session isn't needed on the first transition from external, Strict is safer. For the accurate behavior per value, take MDN: SameSite cookies as the primary source.
5-2. Add Origin/Host verification yourself to state-changing Route Handlers
Since Route Handlers don't have the automatic collation like Server Actions, write the same logic as Server Actions yourself. Confirm "whether the Origin matches your Host (X-Forwarded-Host in a proxy environment)" and reject on a state-changing method.
// lib/csrf.ts — Server Actions の標準保護を Route Handler でも再現する
// 方針:状態変更メソッドのとき、Origin を自分の Host と照合する
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
export function assertSameOrigin(req: Request): void {
if (SAFE_METHODS.has(req.method)) return; // 読み取りは対象外
const origin = req.headers.get("origin");
// プロキシ配下では X-Forwarded-Host が実ホスト。無ければ Host を使う
const host = req.headers.get("x-forwarded-host") ?? req.headers.get("host");
// Origin が無い状態変更は、出所を確認できないので拒否(fail-secure)
if (!origin || !host) throw new Response("Forbidden", { status: 403 });
// Origin のホスト部と、リクエストの宛先ホストが一致するか
if (new URL(origin).host !== host) {
throw new Response("Forbidden", { status: 403 });
}
}
// app/api/account/route.ts — 修正:5-2 の検証を入口で適用
import { cookies } from "next/headers";
import { assertSameOrigin } from "@/lib/csrf";
export async function POST(req: Request) {
assertSameOrigin(req); // ← この1行で別オリジン由来のPOSTを弾く
const session = (await cookies()).get("session")?.value;
if (!session) return new Response("unauthorized", { status: 401 });
const { email } = await req.json();
await updateAccountEmail(session, email);
return Response.json({ ok: true });
}
The point is "reject a state change with no Origin (fail-secure)." Since modern browsers send Origin on POST, suspecting "a route outside the browser" or "an anomaly" on a missing Origin is the safe side. Note that for an API legitimately hit from multiple domains, instead of the host comparison, collate with an allowlist of permitted origins (new Set(["https://app.example.com"]).has(origin)). This verification can also be consolidated in middleware, but if state-changing routes are few, making it explicit at the entrance conveys the intent to the reader better. The story of designing it to work cross-cuttingly is organized in the security-headers and CSP implementation guide.
5-3. In a reverse-proxy environment, set serverActions.allowedOrigins
Server Actions' default collation looks at "Origin == Host." If, in a reverse proxy or multi-layer backend, the domain visible to the user and the Host the server recognizes diverge, even legitimate requests may be erroneously aborted. The official provides a setting option for this case.
// next.config.js — プロキシ配下で正規Originを明示する(公式の案内どおり)
/** @type {import('next').NextConfig} */
module.exports = {
experimental: {
serverActions: {
// 自サイトの正規オリジンだけを列挙する。ワイルドカードは最小限に
allowedOrigins: ["app.example.com", "*.app.example.com"],
},
},
};
The caveat is not to widen it too much. allowedOrigins is a relaxation to "pass even if Origin/Host don't match," and adding an extra domain here loosens the default protection by that much. Enumerate only the necessary legitimate domains.
5-4. For routes you're still uneasy about, a double-submit / synchronizer token
For high-risk operations where you can't stop cookie authentication and Origin verification alone is uneasy (fund transfers, privilege changes, etc.), add the classic synchronizer token or double-submit cookie. The idea is "add a value the browser doesn't automatically attach to the match condition" — the attacker can't send that value from the trap site.
// double-submit の最小例:ランダム値を Cookie とリクエストボディの両方で要求し、一致を見る
// 攻撃者はCookieは自動送信できても、その値を「読んでヘッダー/ボディに載せる」ことはできない
import { cookies } from "next/headers";
export async function POST(req: Request) {
const cookieToken = (await cookies()).get("csrf")?.value;
const sentToken = req.headers.get("x-csrf-token");
if (!cookieToken || !sentToken || cookieToken !== sentToken) {
return new Response("Forbidden", { status: 403 });
}
// 本処理…
}
But honestly, in most cases, 5-1 to 5-3 (POST unification + SameSite + Origin/Host verification) are enough. Since the token method raises the cost of implementation and handling, positioning it as an additional layer for routes where Origin verification can't be relied on, or where regulatory requirements demand multiple defense, is realistic. OWASP's verification viewpoints (OWASP ASVS) and testing procedure (OWASP Web Security Testing Guide) also demand that the CSRF countermeasure be verified in layers, not as a "single silver bullet."
6. Re-verifying authentication/authorization — a CSRF countermeasure can't substitute for it
Let me emphasize the official proviso once more. Neither the default CSRF protection nor the Origin verification added in chapter 5 judges at all "whether this user has the privilege over this operation/this resource." Since a Server Action is reachable by a direct POST, even an action not shown in the UI can be hit from external. So verify authentication and authorization every time inside each action/handler.
// app/actions.ts — Server Action 内で本人確認+所有権を必ず再検証する
"use server";
import { auth } from "@/lib/auth";
export async function deletePost(postId: string) {
// 1) 認証:ページの認証チェックはアクションに引き継がれない。ここで必ず確認
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
// 2) 認可:postId は呼び出し側が自由に変えられる。所有権を確認(IDOR対策)
const post = await getPost(postId);
if (post.authorId !== session.user.id) throw new Error("Forbidden");
await removePost(postId);
}
If you forget to bind postId with ownership here, it becomes a vulnerability of a separate lineage from CSRF (IDOR). CSRF (the problem of origin) and IDOR (the problem of privilege) are orthogonal, separate risks, and the countermeasure for one doesn't stand in for the other. This distinction, and the overall map of the application layer including authorization and RLS, is systematized in the Next.js × Supabase application-security complete guide. Rate limiting to suppress abuse of state-changing routes is carved out in the serverless rate-limiting implementation guide, and countermeasures to not have the redirect destination abused are carved out in the open-redirect prevention guide.
7. This is "horizontal control" — a layer you can firm up uniformly
Let me re-grasp the nature of the countermeasures so far, stepping back one level. CSRF / Origin countermeasures belong to "horizontal control," which can be uniformly stood in for by a library and configuration without knowing the app-specific data model. It differs fundamentally in the range automation reaches from authorization (a vertical risk), which requires an app-specific judgment like "whether this user owns this row."
| Layer | Examples | Reach of automation | Who protects |
|---|---|---|---|
| Horizontal control | CSRF/Origin, SameSite cookies, security headers, rate limiting | can be applied uniformly by config/library | platform + config |
| Vertical risk | authorization/IDOR, tenant isolation, business logic | up to detection/warning. Fix = design is human | human design |
That's exactly why horizontal control like CSRF/Origin shouldn't be premised on "a human writes it correctly every time," but the correct answer is to firm it up once and let detection stand guard. The OSS Aegis I publish supports the detection of this horizontal control — it mechanically points out common CSRF/Origin gaps like places where a state-changing Route Handler has no Origin/Host verification, the suspicion of causing a side effect with GET, and cookies with no SameSite set.
# インストール不要・設定不要でスキャン(CSRF/Origin を含む水平統制の抜けを可視化)
npx @aegiskit/cli scan
I don't overclaim. What Aegis sees is "the form of the code and config," not the "meaning" of your app. It can detect common CSRF/Origin gaps, but doesn't prove that the CSRF countermeasure is perfect. Much less, the correctness of authorization (IDOR), which is orthogonal to CSRF, must be protected separately by design and human review. Detection isn't a replacement for human judgment but a complement to it. For details, see Aegis.
8. Pre-production checklist
Whether outsourced or AI-made, confirm at least this before shipping a Next.js app that handles state changes to production.
- You don't provide operations with side effects via GET (GET change is the worst CSRF surface that passes straight through SameSite=Lax)
- You verify Origin/Host on state changes in Route Handlers (route.ts) (Server Actions' automatic collation doesn't work on route.ts)
- On the authentication cookie, you make
SameSite,HttpOnly, andSecureexplicit (don't fully rely on the browser default) - If under a reverse proxy, you set
serverActions.allowedOriginswith only legitimate domains - You reject a state change with no Origin (fail-secure) (suspect a route outside the browser)
- You re-verify authentication and authorization inside each Server Action / Route Handler (the page's authentication isn't inherited by the action)
- The ownership check (IDOR countermeasure) is in state changes too (CSRF and IDOR are different things)
- If you share cookies across subdomains, you've considered the trust boundary and the necessity of
SameSite=Strict - For high-risk operations, you've added a double-submit / synchronizer token as needed
- You continuously detect CSRF/Origin gaps with a CI scan
What's most effective from the client's/reviewer's viewpoint is the three questions "Does the state-changing API look at Origin?" "Is there a route causing a side effect outside the Server Action (route.ts)?" "Is the page's authentication re-confirmed inside the action too?" A good developer can answer immediately.
9. How far by default, and where yourself — the honest line
Finally, let me draw the line honestly.
Next.js Server Actions' default protection works properly. POST-only + Origin/Host collation, combined with modern browsers' SameSite default, rejects the majority of common CSRF out of the box. The claim "Server Actions' CSRF countermeasure is vulnerable" is inaccurate, and the fact is even bare Server Actions with no configuration have a degree of resistance.
On the other hand, that protection is limited to the single route of Server Actions and doesn't stand in for authentication/authorization. State changes in a Route Handler, cookie-only authentication, subdomain sharing, Host mismatch under a proxy — these aren't protected unless you protect them explicitly. The official itself flatly says "treat Server Actions as reachable by a direct POST, and verify authentication/authorization inside each action." Don't overtrust the default, and plug only the holes the default doesn't reach at the minimal cost of chapter 5 — this is the correct attitude.
And here too, a line is needed. Horizontal control like CSRF/Origin can mechanically crush the majority with automation and detection, but the "correctness" of authorization (IDOR) and tenant isolation can only be judged by a human who understands your data model. How far to firm up yourself, and where an expert's review is needed — if you need a design review of an existing Next.js app's CSRF/Origin, authentication flow, and authorization, I take it on with a security audit. I myself, on a serverless payment platform in the environmental field, designed and operated origin verification, authentication, and ownership enforcement of state-changing routes as a production payment-reliability layer. There's no magic to make it completely safe. What there is, is only the discipline of accurately knowing the scope protected by default and hardening the rest in layers.
Frequently asked questions (FAQ)
Q. If I use Server Actions, don't I need a CSRF countermeasure anymore?
A. If the route is Server Actions only and you re-verify authentication/authorization inside each action, regarding CSRF the default protection (POST-only + Origin/Host collation) handles the majority. But the moment you create even one state change in route.ts, that route is outside the automatic protection, so you need to add Origin/Host verification yourself.
Q. With an encrypted action ID, isn't it safe since the endpoint can't be guessed? A. That's a wall of secrecy, not a wall of CSRF. The protagonists that stop CSRF are POST-only and Origin/Host collation. The ID encryption is a mechanism to "remove unused actions from the bundle" and "make the path hard to guess"; don't overrate it as a CSRF countermeasure.
Q. If I set CORS, can I prevent CSRF? A. Not directly. CORS controls cross-origin response reading, but the side effect CSRF targets happens first on the server side (CORS doesn't stop the sending of a simple request itself). CSRF needs countermeasures that sever "origin / automatic attachment," like Origin/Host verification and SameSite cookies.
Q. If I set SameSite=Strict, is Origin verification unnecessary?
A. The risk drops greatly, but I can't say it's unnecessary. Even with Strict, the premise collapses if you share cookies with a low-trust subdomain, or with old browsers / non-browser routes. As defense-in-depth, using Origin/Host verification of state changes together is solid.
Q. Even for personal development or small scale, should I go this far? A. At minimum, always do the three: "don't make state changes GET," "Origin verification on state changes in route.ts," and "SameSite/HttpOnly/Secure on the authentication cookie." The cost is slight, and since CSRF has many classic and automated attacks, the bare state is dangerous.
Summary: accurately know the default, and plug only the remaining holes in layers
Let me organize the key points.
- CSRF is an attack that "makes an unintended state change with the victim's cookie." What's targeted is operations with side effects, and the core of the countermeasure is "don't change state with GET" and "confirm the request's origin."
- Next.js Server Actions have, by default, POST-only + Origin/Host collation + encrypted action IDs (within the scope the official shows). This is useful CSRF resistance, but works only on the single route of Server Actions and doesn't stand in for authentication/authorization.
- There are four remaining holes — Route Handler (route.ts) GET/POST, cookie-only authentication, subdomain sharing, and Host mismatch under a proxy. The default protection doesn't reach here.
- The defenses to add — POST unification of state changes, making SameSite cookies explicit, Origin/Host verification in route.ts,
serverActions.allowedOrigins, and a double-submit if needed. Most are fine with the first three. - CSRF/Origin is horizontal control, firmed up uniformly by config, library, and detection. But a tool only helps detect the gaps and proves neither the perfection of the CSRF countermeasure nor the correctness of the orthogonal authorization.
Building fast with AI is itself correct. Firming up the "origin" and "privilege" of what you built fast, without leaks — if you need to build that mechanism, or a review of an existing Next.js app's CSRF/Origin, authentication, and authorization, please feel free to consult me.
References
- OWASP Top 10 (the major risks of web apps)
- OWASP Application Security Verification Standard (ASVS, verification viewpoints)
- OWASP Web Security Testing Guide (CSRF testing procedure)
- MDN — Set-Cookie's SameSite attribute (Lax/Strict/None behavior)
- Next.js Docs (Server Actions' security and allowedOrigins)