最近、Next.jsがどのようにCSRF対策をしているのかを考える機会がありました。
そんななかで色々と学んだことを今回は書きます。
CSRFとは
本題に入っていく前に、一度CSRFについて改めて復習しておきましょう。CSRF(Cross-Site Request Forgery)とは、ユーザーが意図しないリクエストを攻撃者が勝手に送信させる攻撃です。以下に攻撃の一連の流れを可視化しています。
sequenceDiagram
participant User as ユーザー
participant Bank as 銀行サイト (bank.example)
participant Evil as 悪意のあるサイト (evil.example)
User->>Bank: ログイン(セッションCookieを取得)
User->>Evil: 悪意のあるページを開く
Evil->>User: 自動送信フォームを含むHTMLを返す
User->>Bank: POST /transfer (Cookieが自動付与される)
Bank->>Bank: セッションCookieが有効 → 振込実行!
悪意あるページevil.exampleが返す自動送信フォームは以下のようなイメージです。送信者のtoの値を攻撃者のものに、金額を任意の(大きな)額にしておきます。
<body onload="document.forms[0].submit()"> <form action="https://bank.example/transfer" method="POST"> <input type="hidden" name="to" value="attacker"> <input type="hidden" name="amount" value="100000"> </form> </body>
この攻撃が成立するのは、ブラウザの仕様のせいです。MDN HTTP cookies には次のように記載されています。
When a new request is made, the browser usually sends previously stored cookies for the current domain back to the server within a Cookie HTTP header
基本仕様として、ブラウザは同じサーバーへのリクエストにCookieを自動で付与します。そのため、evil.exampleからsubmitする際にも、bank.exampleにログイン済みであればセッションCookieを使ってリクエストが実行されるというわけです。
一方でサーバー側からすると、そのリクエストが正規のページから来たものか、悪意のあるページから来たものか区別できません。
よくあるCSRF対策
上記の通り、CSRF攻撃は サーバー側でリクエストが正規のものか? を検証することができれば成立しません。
この検証方法として長らく使われてきたのが「CSRF Token」を使う方法です。この方法はOWASPのSRF Cheat Sheetや徳丸先生の著書・安全なWebアプリケーションの作り方でも推奨されている方法です。
この方法では以下のようにしてリクエストが正規のページからきたものか?を検証します。
- サーバー側であらかじめ、推測困難なランダム文字列のTokenを生成する。作成したTokenはセッションIDと紐づけて保存する。
- このTokenをレスポンスに含める。よくあるのが
hiddenフィールドに含める方式です。 - クライアントからはこのTokenを含めてサーバー側へリクエストを送る
- 送られてきたTokenとセッションIDを保存済みのそれと比較し一致することを確認する
つまり一時的な合言葉を決めておき、リクエストの際には合言葉を使ってリクエストを検証するというものです。この合言葉を知っているのは、直接サーバーがレスポンスしたクライアントだけです。これはSame-Origin Policy があることで、攻撃者は他人のTokenを取り出せない = 攻撃者のformにはTokenを埋め込む手段がないからです。
sequenceDiagram
participant User as ユーザー
participant Server as サーバー
participant Evil as 悪意のあるサイト
User->>Server: GETリクエスト(フォームページを取得)
Server->>User: フォーム + CSRF Token(hidden field)を返す
User->>Server: POST /transfer + CSRF Token
Server->>Server: セッションのTokenと比較 → 一致 → 処理実行
Evil->>User: 自動送信フォームを含むHTMLを返す
User->>Server: POST /transfer(CSRF Tokenなし)
Server->>Server: Token 不一致 → リクエスト拒否!
LaravelでのCSRF対策を例に
ここで具体例として、PHPフレームワークのLaravelを見ておくことにします*1。LaravelはToken方式によってCSRF対策をしています。LaravelをチョイスしたのはToken方式による対策をしていることと、自分が過去にLaravelのCSRF対策に関する記事を書いていて説明が楽だからです。
詳細は過去の自分による上記の記事に譲るとして、ここでは簡潔に話します。
LaravelではBlade*2に @csrf と書くだけで、hidden inputとTokenを自動生成してくれます。
<form method="POST" action="/transfer"> @csrf <!-- <input type="hidden" name="_token" value="Token(ランダムな文字列)"> --> </form>
Tokenはセッション初期化時にランダムな文字列として生成され、サーバー側のセッションファイルに保存されます。初期設定の状態では、セッションIDをファイル名として以下のようにTokenを保存します。
a:4:{s:6:"_token";s:40:"G5FzKXaCYA4w8kdWbftEZMYoglQgD9yPIG9r2zzx";s:9:"_previous";a:1:{s:3:"url";s:29:"http://127.0.0.1:8085/profile";}s:6:"_flash";a:2:{s:3:"old";a:0:{}s:3:"new";a:0:{}}s:50:"login_web_59ba36addc2b2f9401580f014c7f58ea4e30989d";i:1;}
そしてformからのsubmitの際にVerifyCsrfToken ミドルウェアがリクエストを検査し、Tokenが一致しない場合は拒否します。またGET, HEAD, OPTIONSメソッドによるリクエストは検査対象外としています。余談ですがLaravelはhash_equals() を使うことでタイミング攻撃にも耐性を持たせているみたいです。
/**
* Determine if the session and input CSRF tokens match.
*
* @param \Illuminate\Http\Request $request
* @return bool
*/
protected function tokensMatch($request)
{
$token = $this->getTokenFromRequest($request);
return is_string($request->session()->token()) &&
is_string($token) &&
hash_equals($request->session()->token(), $token);
}
/**
* Determine if the HTTP request uses a ‘read’ verb.
*
* @param \Illuminate\Http\Request $request
* @return bool
*/
protected function isReading($request)
{
return in_array($request->method(), ['HEAD', 'GET', 'OPTIONS']);
}
CSRFの本質的な問題とは?
ここまでToken方式によるCSRF対策を見てきました。しかしよく考えると、CSRF Token方式では結局何を確認しているのでしょうか?Tokenが一致すること...は本質ではありません。
過去にLaravelのCSRF対策の記事を書いたときは、まさにここで思考が止まっていました。Token方式の対策方法を知っただけで満足していました。
CSRF対策の本質。その答えはJackさんの記事に詳しく書かれています。
先に結論から言うと、CSRFの本質的な問題は「リクエストの出自(どこから来たか)がわからないこと」です。CSRF Token方式はこれを明らかにするための一つの手段に過ぎません*3。
また、Cookieをクロスサイトで送ってしまうことも問題です。クロスサイトからのリクエストの際にCookieがなければ、そもそも認証が通らずリクエストが失敗します。
この2点に着目しながら、Jackさんの記事を見ていきます。
令和時代のCSRF対策
Tokenを使わずリクエストの出自を知る
これはOriginヘッダの値を見ることでリクエストの出自を知ることができます。
ブラウザはクロスオリジンリクエストや同一オリジンのPOSTリクエストに Origin ヘッダを自動で付与します。値はリクエストを発生させたページのオリジン(スキーム+ホスト名+ポート)であり、パスは含みません。
MDN: Origin に記載の内容を整理すると、Origin ヘッダは以下のパターンで付与されます。
| 条件 | Origin付与 | 備考 |
|---|---|---|
| クロスオリジン × GET/HEAD | ✅ | ただし後述の例外あり |
| クロスオリジン × POST/PUT/PATCH/DELETE/OPTIONS | ✅ | |
| 同一オリジン × POST/PUT/PATCH/DELETE/OPTIONS | ✅ | GET/HEAD以外 |
| 同一オリジン × GET/HEAD | ❌ | 付与されない |
つまり最初のCSRF攻撃の例に基づくと、
- 正規リクエスト
Origin: https://bank.example
- 攻撃リクエスト
Origin: https://evil.example
となるため、リクエストの出自を知ることができます。
クロスサイトにCookieを送らない
MDN: SameSite cookies によると、SameSite 属性はクロスサイトリクエスト時のCookie送信を制御します。LaxやStrictを設定することで、クロスサイトのPOSTリクエストにはCookieが付与されません。ちなみに最新版Chromeのデフォルト値はLaxです。
| リクエストの種類 | Strict | Lax | None |
|---|---|---|---|
| 同一サイトからのリクエスト | 送信 | 送信 | 送信 |
| 別サイトからのリンククリック(トップレベルナビゲーション) | 送信しない | 送信 | 送信 |
| 別サイトからのPOSTフォーム | 送信しない | 送信しない | 送信 |
| 別サイトからのfetch/XHR | 送信しない | 送信しない | 送信 |
| 別サイトからのiframe内読み込み | 送信しない | 送信しない | 送信 |
このため、攻撃者のサイトからPOSTしてもCookieが届かないため、認証が通りません*4。
令和時代のCSRF対策
Jackさんの記事では次の優先順位が示されています。
- POSTメソッドの使用*5
- Originヘッダの確認
- SameSite Cookieの明示的な設定
- Fetch Metadataの確認
1. POSTメソッドの使用
副作用(データの変更・削除など)を伴う操作には必ずPOSTを使います。先の表の通り、GETはクロスサイトからのリクエスト時にOriginが付与されません*6。そのため、読み取り専用として副作用を起こさないことが前提です。
2. Originヘッダの検証
サーバー側でこの値を確認するだけで、出自を直接検証できます。
3. SameSite Cookie
SameSite Lax/Strict を明示することでクロスオリジンにCookieが送信されません。
4. Fetch Metadata(Sec-Fetch-Site)
Sec-Fetch-Site ヘッダを使うと、リクエストがどこから来たかをより詳細に把握できます。
Sec-Fetch-Site: same-origin # 同じオリジンから Sec-Fetch-Site: cross-site # 別ドメインから Sec-Fetch-Site: none # 直接アクセス(URLバーなど)
Next.js 公式ブログで紹介されているアプローチ
前置きが非常に長くなりましたが、ここからが本題です。実は、Next.jsの公式ブログでもNext.jsが上述のアプローチにかなり近い方法でCSRF対策を施していることが紹介されています。
公式ブログには次のように明記されています。
Behind the scenes, Server Actions are always implemented using POST and only this HTTP method is allowed to invoke them. This alone prevents most CSRF vulnerabilities in modern browsers, particularly due to Same-Site cookies being the default.
As an additional protection Server Actions in Next.js 14 also compares the
Originheader to theHostheader (orX-Forwarded-Host). If they don't match, the Action will be rejected.Server Actions doesn't use CSRF tokens, therefore HTML sanitization is crucial.
整理すると Next.jsのServer Actionsは三段構えでCSRF対策をしている ようです。
| 対策 | 仕組み |
|---|---|
| SameSite Cookie | クロスサイトのPOSTにはCookieが付かない |
| POSTのみ許可 | GET等のメソッドではServer Actionが起動しない |
| Origin / Host比較 | OriginとHostが一致しない場合はリクエスト拒否 |
つまりNext.jsのCSRF対策は従来のToken方式ではなく、上述の令和時代のCSRFに沿ったものになっているようです。
Next.jsのソースコードを読む
公式ブログが「対策してますよ」と言ってるとはいえ「本当に?」という気持ちが少なからず残っています。
なので、実際にソースコードを確認してPOSTのみ許可とOrigin / Hostの比較をどう実装しているのかを確認します*7。
全体フロー図
処理の流れが複雑*8なので、まずはCSRFの検証をするまでの動きを俯瞰しておきます*9。
flowchart TD
START["Server Actions 発火<br/>app-render.tsx:2195-2197<br/>handleAction() 呼び出し"]
START --> A["action-handler.ts:556-562<br/>getServerActionRequestMetadata(req)"]
A --> B{"isPossibleServerAction"}
B -->|false| B_NG["return null<br/>(行585-586)"]
B -->|true| C["action-handler.ts:618-622<br/>Origin ヘッダー取得<br/>originDomain = new URL(origin).host"]
C --> D["action-handler.ts:623<br/>parseHostHeader(req.headers)<br/>Host / x-forwarded-host 取得"]
D --> E{"Origin vs Host 比較"}
E -->|"Originなし"| E_WARN["⚠️ warning のみ<br/>古いブラウザの場合に配慮し、処理は継続"]
E -->|"一致"| OK["✅ CSRF検証パス<br/>Server Actions 実行"]
E -->|"不一致"| F["csrf-protection.ts:72<br/>isCsrfOriginAllowed()"]
F --> G{"allowedOrigins<br/>に含まれる?"}
G -->|"はい"| OK
G -->|"いいえ"| NG["❌ 500エラー<br/>'Invalid Server Actions request.'<br/>action-handler.ts:663"]
style B_NG fill:#f9f,stroke:#333
style NG fill:#f66,stroke:#333,color:#fff
style OK fill:#6f6,stroke:#333
style E_WARN fill:#ffe,stroke:#333
Server Actionについて
補足として、Server Actionについてさっくりと書いておきます。Server Actionは、クライアントから直接呼び出せるサーバーサイド関数です。
// app/actions.ts
'use server' // ← この宣言でServer Actionになる
export async function createUser(formData: FormData) {
const name = formData.get('name')
await db.insert({ name }) // サーバー上でDBに直接アクセスできる
}
// app/page.tsx
import { createUser } from './actions'
export default function Page() {
return (
<form action={createUser}> {/* ← Server Actionをformに渡す */}
<input name="name" />
<button type="submit">送信</button>
</form>
)
}
'use server' で宣言された関数には、ビルド時に一意のactionIdが割り振られます。クライアント側ではその関数呼び出しが POSTリクエストに自動変換されます*10。
もっと詳しく知りたい!という方は、カミナシさんのこちらの記事が詳しいので、ぜひ読んでみてください。
kaminashi-developer.hatenablog.jp
POSTのみ許可する仕組み
まず前の節でも触れた通り、Server ActionsはPOSTリクエストでのみ起動するようになっています。
そのうえで、サーバー側でリクエストを受けるとapp-render.tsxのhandleAction()が呼び出され、さらにhandleAction()からaction-handler.tsのgetServerActionRequestMetadata(req)が呼ばれます。
ここでリクエストがPOSTであることも含め、Server Actionsとして処理可能かどうかのチェックが行われます。
// We don't actually support URL encoded actions, and the action handler will bail out if it sees one. // But we still want it to flow through to the action handler, to prevent changes in behavior when a regular // page component tries to handle a POST. const isURLEncodedAction = Boolean( req.method === 'POST' && contentType === 'application/x-www-form-urlencoded' ) const isMultipartAction = Boolean( req.method === 'POST' && contentType?.startsWith('multipart/form-data') ) const isFetchAction = Boolean( actionId !== undefined && typeof actionId === 'string' && req.method === 'POST' ) const isPossibleServerAction = Boolean( isFetchAction || isURLEncodedAction || isMultipartAction )
このようにServer Actionsではいくつかの防御層によりPOSTリクエストのみを通すようになっています。
Origin / Host比較する仕組み
上述のisPossibleServerActionがtrueの場合、さらにaction-handler.tsのhandleAction()内でOriginとHostの比較が行われます。
処理としては、まずOriginヘッダの値を取得します。Origin: nullの場合はundefinedとして扱います。HostヘッダについてはparseHostHeader()で取得しています。parseHostHeader()はx-forwarded-hostヘッダ > hostヘッダの順で見ていきます。
その上でOriginとHostを比較しています。その結果、
Originヘッダの値が取れなかった場合、警告だけ出して処理続行- 一致した場合は処理続行
- 一致しなかったが
isCsrfOriginAllowedに含まれる場合はOK - 一致せず、かつ
isCsrfOriginAllowedにも含まれない場合は攻撃とみなして処理中止
というかたちでCSRF攻撃を防止しています。
const originHeader = req.headers['origin'] const originDomain = typeof originHeader === 'string' && originHeader !== 'null' ? new URL(originHeader).host : undefined const host = parseHostHeader(req.headers) let warning: string | undefined = undefined function warnBadServerActionRequest() { if (warning) { warn(warning) } } // This is to prevent CSRF attacks. If `x-forwarded-host` is set, we need to // ensure that the request is coming from the same host. if (!originDomain) { // This might be an old browser that doesn't send `host` header. We ignore // this case. warning = 'Missing `origin` header from a forwarded Server Actions request.' } else if (!host || originDomain !== host.value) { // If the customer sets a list of allowed origins, we'll allow the request. // These are considered safe but might be different from forwarded host set // by the infra (i.e. reverse proxies). if (isCsrfOriginAllowed(originDomain, serverActions?.allowedOrigins)) { // Ignore it } else { if (host) { // This seems to be an CSRF attack. We should not proceed the action. console.error( `\`${ host.type }\` header with value \`${limitUntrustedHeaderValueForLogs( host.value )}\` does not match \`origin\` header with value \`${limitUntrustedHeaderValueForLogs( originDomain )}\` from a forwarded Server Actions request. Aborting the action.` ) } else { // This is an attack. We should not proceed the action. console.error( `\`x-forwarded-host\` or \`host\` headers are not provided. One of these is needed to compare the \`origin\` header from a forwarded Server Actions request. Aborting the action.` ) } } }
export function parseHostHeader( headers: IncomingHttpHeaders, originDomain?: string ) { const forwardedHostHeader = headers['x-forwarded-host'] const forwardedHostHeaderValue = forwardedHostHeader && Array.isArray(forwardedHostHeader) ? forwardedHostHeader[0] : forwardedHostHeader?.split(',')?.[0]?.trim() const hostHeader = headers['host'] if (originDomain) { return forwardedHostHeaderValue === originDomain ? { type: HostType.XForwardedHost, value: forwardedHostHeaderValue, } : hostHeader === originDomain ? { type: HostType.Host, value: hostHeader, } : undefined } return forwardedHostHeaderValue ? { type: HostType.XForwardedHost, value: forwardedHostHeaderValue, } : hostHeader ? { type: HostType.Host, value: hostHeader, } : undefined }
isCsrfOriginAllowed
isCsrfOriginAllowedについても見ておきましょう。packages/next/src/server/app-render/csrf-protection.tsに実装があります。
export const isCsrfOriginAllowed = ( originDomain: string, allowedOrigins: string[] = [] ): boolean => { // DNS names are case-insensitive per RFC 1035 // Use ASCII-only toLowerCase to avoid unicode issues const normalizedOrigin = originDomain.replace(/[A-Z]/g, (c) => c.toLowerCase() ) return allowedOrigins.some((allowedOrigin) => { if (!allowedOrigin) return false const normalizedAllowed = allowedOrigin.replace(/[A-Z]/g, (c) => c.toLowerCase() ) return ( normalizedAllowed === normalizedOrigin || matchWildcardDomain(originDomain, allowedOrigin) ) }) }
この関数は、リクエストのOriginヘッダのドメインが、サーバー側で許可されたドメインのリストallowedOriginsに含まれているかどうかをチェックします。Next.jsの公式ドキュメントに記載があります。
A list of extra safe origin domains from which Server Actions can be invoked. Next.js compares the origin of a Server Action request with the host domain, ensuring they match to prevent CSRF attacks. If not provided, only the same origin is allowed.
結論
POSTのみ許可する処理とOrigin/Hostの比較処理がちゃんと実装されていました。これで万事OK!!
...とはいきません。Custom Route Handlers(route.ts)のことを考える必要があります。
実はここまで説明したCSRF保護はすべてServer Actions に対してのみ機能します。Custom Route HandlersはServer Actionsと処理経路が変わるため、別途CSRF対策が必要です。公式ブログも次のように注意を促しています。
When Custom Route Handlers (route.ts) are used instead, extra auditing can be necessary since CSRF protection has to be done manually there.
このためroute.ts においては、令和時代のCSRF対策や従来のCSRF Token方式によるCSRF対策が必要となります。
おわりに
きっかけは何気ないXのポストに結構な反響があったことでした。
Next.jsのCSRF対策の見解、言ってることは分かるが「ほんまか?」と思ってるの僕だけ?笑
— Kanon (@ysknsid25) 2026年2月16日
"How to Think About Security in Next.js"https://t.co/xLZuSSIeDk
おかげで深掘りするきっかけになったし、実際たくさんのことを学べる非常によい機会になりました。
*1:このセクションはToken方式の解像度を上げるためのもので、記事の本題であるNext.jsのCSRF対策の方法にはあまり関係ありません。なので、読み飛ばしていただいても問題ありません。
*2:Laravelのテンプレートエンジン
*3:念のため書いておくと、CSRF Token方式がダメというわけではありません。後述しますが非常に古いブラウザをサポートする必要がある場合など、様々なパターンにおいて機能してくれる手堅い方式であると考えています。
*4:ただしJackさんは先の記事内で、この結果は副次的なものと結論づけています。 https://blog.jxck.io/entries/2024-04-26/csrf.html#:~:text=%E4%BB%8A%20SameSite%20Lax%20%E3%81%8C%20default%20%E3%81%AB%E3%81%AA%E3%81%A3%E3%81%9F%E3%81%AE%E3%82%92%E7%90%86%E7%94%B1%E3%81%AB%E3%80%81%E3%80%8COrigin%20%E3%81%AE%E3%83%81%E3%82%A7%E3%83%83%E3%82%AF%E3%80%8D%E3%82%92%E6%80%A0%E3%81%A3%E3%81%9F%E5%AE%9F%E8%A3%85%E3%82%92%E3%81%97%E3%81%A6%E3%81%84%E3%82%8B%E3%81%AE%E3%81%A7%E3%81%82%E3%82%8C%E3%81%B0%E3%80%81%E3%81%9D%E3%82%8C%E3%81%AF%E6%9C%AC%E8%B3%AA%E7%9A%84%E3%81%AA%E5%AF%BE%E7%AD%96%E3%82%92%E6%80%A0%E3%81%A3%E3%81%9F%E7%89%87%E6%89%8B%E8%90%BD%E3%81%A1%E3%81%AE%E5%AE%9F%E8%A3%85%E3%81%A7%E3%80%81%E3%81%9F%E3%81%BE%E3%81%9F%E3%81%BE%E5%8A%A9%E3%81%8B%E3%81%A3%E3%81%A6%E3%81%84%E3%82%8B%E3%81%A0%E3%81%91%E3%81%A0%E3%81%A8%E8%A8%80%E3%81%88%E3%82%8B%E3%81%A0%E3%82%8D%E3%81%86%E3%80%82
*5:副作用のある API を GET にしないの意
*6:ネイティブformやimgタグ等のno-corsリクエストでは付与されない
*7:SameSite Cookieはブラウザ頼みの仕組みなので確認できません。
*8:実際はもっとステップが多いです。この図では説明の簡略化のために本来であればapp-call-server.ts:callServer()から発火する流れを省略しています。Next.jsが想定するPOSTによるServerActionが発火したところからの流れを見ています。
*9:余談ですが、AIがすぐにMermaidの図を作ってくれるおかげで本当にコードが読みやすくなりました。昔はソースコードリーディング記事を書く時には図式化するのがめちゃ面倒で、ひたすらファイル名とソースコードを並べてました。
*10:next/src/client/components/router-reducer/reducers/server-action-reducer.ts:136





