Next.js Server Actionsのセキュリティチェックリスト|認可・入力検証・CSRF(2026)
Next.jsのServer Actionsを安全に運用するための実務チェックリスト。各アクションを公開POSTエンドポイントとして扱い、認可と所有者確認、入力検証、戻り値の絞り込み、allowedOriginsの設定を整理します。
目次
- 1分要約
- どんな人向けか
- 結論
- 解説
- 「ボタンを隠している」はセキュリティ境界にならない
- フレームワークが守ってくれる範囲
- スキーマ検証は認可ではない
- 戻り値もデータ漏えいの経路になる
- 実務ガイド
- ステップ1:すべてのアクションを洗い出す
- ステップ2:認証・認可を server-only のデータ層へ移す
- ステップ3:入口ですべての引数を検証する
- ステップ4:戻り値とエラーの形を決める
- ステップ5:クロージャと暗号鍵を意図して扱う
- ステップ6:許可するオリジンとボディ上限を狭く設定する
- ステップ7:コストの高いアクションに濫用対策を入れる
- ステップ8:UIを通さずにアクションをテストする
- ありがちな落とし穴
- チェックリスト
- FAQ
- 1. アクションIDが暗号化されているなら、アクションごとの認証チェックは不要では?
- 2. Server ActionsのCSRF対策だけで十分?
- 3. 機密性の高い操作はServer ActionsとRoute Handlersのどちらで書くべき?
- 4. Proxyに認証チェックを置けば、全アクションをまとめて守れる?
- 参考資料
- 内部リンク
- 免責事項
誰でも呼び出せるNext.jsのServer Actionsを、どう守ればいい?
1分要約
- Server ActionsはPOSTエンドポイントです。画面にフォームを出していなくても、同じリクエストを送れる人なら誰でも呼び出せます。
- 認証・認可・リソースの所有者確認は、各アクションの中か、全アクションが必ず通る
server-onlyのデータアクセス層で行います。ページ、レイアウト、Proxyでのチェックはアクションを守りません。 - 引数はすべて検証し、ユーザーIDはセッションから取り出し、戻り値はUIが表示する分だけに絞ります。
serverActions.allowedOriginsは実際に使う公開ホストだけに限定します。
どんな人向けか
- Next.js App Routerで
'use server'を使って更新処理を書いているチーム - AIが生成したコードや、急いで出したNext.jsのコードをレビューする人
- API RoutesからServer Actionsに移行し、同じセキュリティ水準を保ちたいエンジニア
結論
Server ActionsはAPIルートを書く手間を減らしますが、APIそのものが無くなるわけではありません。Next.jsの公式ドキュメントも、すべてのアクションを信頼できない入口として扱うよう求めています。ビルド時、クライアント側のバンドルでは関数本体がアクションIDとディスパッチャーに置き換わり、それがサーバーへPOSTします。このリクエストはUIを経由しなくても再現できます。
Next.jsはフレームワーク側でもいくつかの防御を用意しています。CSRF対策の Origin と Host の照合、デフォルト1MBのボディサイズ上限、暗号化されて定期的に変わるアクションID、使われていないアクションの除去(dead code elimination)、クロージャで捕捉した変数の暗号化です。ただし、どれも「このユーザーがこのレコードを変更してよいか」は判断しません。その判断は、データに近い場所で自分のコードが行う必要があります。
各アクションの基本形は次のとおりです。
- サーバー側でセッションを読む
- 入力をパースして検証する
- 対象リソースに対して操作を認可する
server-onlyのデータ層を通して更新する- キャッシュを再検証し、最小限の結果を返す
解説
「ボタンを隠している」はセキュリティ境界にならない
App Routerでは、ページが未ログインのユーザーをリダイレクトしていても、そのページ上のフォームはアクションを参照したままです。Next.jsのデータセキュリティガイドは、ページ単位の認証チェックはそのページ内で定義されたServer Actionsには及ばないと明記しています。ページが制御するのは表示されるUIであり、アクションは別の入口です。
レイアウトとProxy(proxy.ts。Next.js 15まではMiddleware)も同様です。認証ガイドはProxyでのチェックを「楽観的なチェック」と位置付け、Proxyを唯一の防御線にすべきではないとしています。レイアウトは画面遷移のたびに再レンダリングされるとは限らないため、そこでのチェックは古い状態のまま使われることもあります。
フレームワークが守ってくれる範囲
以下は現行のNext.jsドキュメント(v16.3)に基づく整理です。
| 防御 | 何をするか | 何をしないか |
| ------------------------- | -------------------------------------------------------------------------------------------- | ------------------------------------------------ |
| POSTでのみ呼び出し | アクションはPOSTでしか実行されず、GETによる意図しない副作用を防ぐ | ログイン済みの攻撃者による直接POSTは止めない |
| Origin と Host の照合 | Origin のホストが Host または X-Forwarded-Host と異なれば拒否する | Origin ヘッダーが無いリクエストは拒否しない |
| ボディサイズ上限 | リクエストボディをデフォルトで1MBに制限する | 中身の検証や呼び出し回数の制限はしない |
| 暗号化されたアクションID | IDを推測不能にし、新しいビルドで再生成する。キャッシュは最大14日 | UIが実際に使っているアクションを隠しはしない |
| dead code elimination | どこからも使われていないアクションをクライアントバンドルから除き、公開エンドポイントにしない | どこかで参照されているアクションは守らない |
| クロージャ変数の暗号化 | インラインアクションが捕捉した値を、クライアントに渡る前に暗号化する | 秘密情報をクロージャに入れてよい理由にはならない |
レビューで特に効くのは次の2点です。1つ目は、serverActions の設定リファレンスによると、Origin ヘッダーが無いリクエストは拒否されず、警告付きで通過することです。CSRFチェックはブラウザ経由の攻撃リスクを下げるものであって、認証ではありません。2つ目は、データセキュリティガイドが、クロージャ内の機密値をクライアントから隠す手段を暗号化だけに頼ることを推奨していない点です。
スキーマ検証は認可ではない
ZodやValibotのスキーマが確認するのは入力の「形」です。Server Actionsガイドが指摘しているとおり、形として正しいオブジェクトでも、呼び出し元が所有していない行を指している可能性があります。IDや所有者を含む Item オブジェクトをまるごとクライアントから受け取るアクションは、POSTできる人なら誰でも任意のアイテムを書き換えられます。
安全な受け渡しは、クライアントからは参照(通常はID)と変更内容だけを送り、サーバー側でセッションからユーザーを特定し、所有者条件付きでリソースを読み出す形です。これは当サイトのIDOR記事で扱ってきたオブジェクト単位の認可と同じ問題で、通り道が変わっただけです。
戻り値もデータ漏えいの経路になる
アクションの戻り値はシリアライズされてクライアントに送られます。db.user.update(...) の結果をそのまま返すと、パスワードハッシュや内部フラグ、リレーション経由で取れた他ユーザーのデータまで漏れることがあります。UIが必要とするフィールドだけを持つオブジェクトを明示的に返してください。
実務ガイド
ステップ1:すべてのアクションを洗い出す
ディレクティブを検索し、エクスポートされたアクションとインラインのアクションごとに、呼び出し元、扱うデータ、呼び出してよい人を一覧にします。
grep -rn --include='*.ts' --include='*.tsx' "'use server'" app lib src
dead code eliminationで除かれるのは、どこからも参照されていないアクションだけです。フォーム、formAction、useActionState のいずれかで使われているアクションは外から到達できます。
ステップ2:認証・認可を server-only のデータ層へ移す
アクション自体は薄く保ち、どの呼び出しも必ず通る場所にチェックを置きます。
// lib/dal/posts.ts
import 'server-only';
import { requireUser } from '@/lib/dal/session';
import { db } from '@/lib/db';
export async function renamePost(postId: string, title: string) {
const user = await requireUser(); // 有効なセッションが無ければ例外またはリダイレクト
const post = await db.post.findFirst({
where: { id: postId, authorId: user.id },
select: { id: true },
});
if (!post) throw new Error('Not found');
await db.post.update({ where: { id: post.id }, data: { title } });
}
所有者条件を付けた findFirst を使うと、「存在しない」と「自分のものではない」が同じ結果になるため、他人のIDが実在するかどうかを相手に教えずに済みます。
ステップ3:入口ですべての引数を検証する
// app/posts/actions.ts
'use server';
import { z } from 'zod';
import { revalidatePath } from 'next/cache';
import { renamePost } from '@/lib/dal/posts';
const RenameInput = z.object({
postId: z.string().uuid(),
title: z.string().trim().min(1).max(120),
});
export async function renamePostAction(_prev: unknown, formData: FormData) {
const parsed = RenameInput.safeParse({
postId: formData.get('postId'),
title: formData.get('title'),
});
if (!parsed.success) return { ok: false as const, error: 'Invalid input' };
await renamePost(parsed.data.postId, parsed.data.title);
revalidatePath('/posts');
return { ok: true as const };
}
FormData は項目を指定して取り出します。フォームの値をORMの data にそのまま展開してはいけません。フォームに無い role、authorId、isPaid などの列をクライアントが勝手に設定できてしまいます(マスアサインメント)。
ステップ4:戻り値とエラーの形を決める
{ ok: true } や { ok: false, error: 'Invalid input' } のような小さな判別可能な結果を返します。生のレコード、スタックトレース、特定のIDやメールアドレスが存在するかどうかが分かるメッセージは返しません。詳しいエラーはリクエストIDと一緒にサーバー側のログに残します。
ステップ5:クロージャと暗号鍵を意図して扱う
インラインアクションはレンダリング時の値を捕捉でき、Next.jsはそれを暗号化してからクライアント経由で往復させます。捕捉するのはバージョン番号のような機密性の低いスナップショットだけにし、セキュリティに関わる値はサーバー側で読み直します。
複数インスタンスでセルフホストする場合は、全インスタンスに同じ NEXT_SERVER_ACTIONS_ENCRYPTION_KEY を設定します。形式は、base64でエンコードした16・24・32バイトのAES鍵です(例:openssl rand -base64 32)。シークレットとして保管し、他の鍵と同じようにローテーションを計画してください。
ステップ6:許可するオリジンとボディ上限を狭く設定する
ブラウザから見えるホストとサーバーが認識するホストが一致していれば、allowedOrigins は設定不要です。公開ホストを X-Forwarded-Host で転送するリバースプロキシの背後でも同じです。追加するのは、ユーザーが実際に目にする公開ホストだけにします。
// next.config.js
module.exports = {
experimental: {
serverActions: {
allowedOrigins: ['app.example.com'],
bodySizeLimit: '1mb',
},
},
};
ワイルドカードには決まりがあります。*.example.com はちょうど1ラベル分にだけ一致し、example.com 自体には一致しません。** は1ラベル以上に一致し、パターンの先頭でしか使えません。ポートはワイルドカードにできないため、省略せずに書きます。他チームや他テナントが管理するホストまで含む広いパターンは避けてください。bodySizeLimit はアップロードが必要な場合だけ上げ、multipartのオーバーヘッド分の余裕も見込みます。
ステップ7:コストの高いアクションに濫用対策を入れる
メール送信、有料APIの呼び出し、大量の書き込みを行うアクションには、ユーザーとIPをキーにしたレート制限を入れます。削除のような破壊的な操作について、Next.jsのガイドは、より強いセッション確認や再認証を求めること、チェックに漏れがあれば明示的に失敗させることを勧めています。
ステップ8:UIを通さずにアクションをテストする
アクションのモジュールを直接呼び出すテストを書き、未ログインのユーザー、リソースを所有していないユーザー、余分なフォーム項目を付けたリクエストの3パターンで試します。どれもデータを変えずに失敗するのが正解です。レビューで問うべきは「UIから呼べるか」ではなく、「誰かがこれをPOSTしたら何が起きるか」です。
ありがちな落とし穴
- ページのリダイレクト、レイアウトのチェック、Proxyのmatcherでアクションを守ったつもりになる
- ログインしているかは確認するが、そのレコードの所有者かは確認しない
- オブジェクト全体、所有者ID、ロールをクライアントから受け取る
Object.fromEntries(formData)をORMのupdateにそのまま渡す- アクションから生のデータベースレコードを返す
- トークン、シークレット、非公開フィールドをインラインアクションのクロージャで捕捉する
- CSRFチェックが呼び出し元を認証してくれる、
Originが無いリクエストは拒否されると思い込む X-Forwarded-Hostの設定を直さず、エラーを消すために広いallowedOriginsを追加する- 1つのアップロードフォームのために
bodySizeLimitを全体で引き上げる - メール、AI、決済系のアクションにレート制限を入れない
'use server'ファイルから未使用のヘルパー関数までエクスポートする- データベースの詳細なエラーをクライアントに表示する
チェックリスト
- [ ] すべての
'use server'ファイルとインラインアクションを、呼び出し元と扱うデータの範囲とともに一覧化した - [ ] 各アクションが、ページ・レイアウト・Proxyだけでなくアクション内でサーバー側のセッションを確認している
- [ ] 各アクションが、ログイン状態だけでなく対象リソースに対する認可を確認している
- [ ] 所有者の確認をクエリ条件で強制している(例:
idとauthorIdの両方) - [ ] ユーザーIDはフォーム項目や引数ではなく、必ずセッションから取得している
- [ ] すべての引数を、型・長さ・形式を定めたスキーマでパースしている
- [ ] ORMへの書き込みは許可するフィールドを明示し、フォームの値を展開していない
- [ ] データベースへのアクセスは
server-onlyのデータアクセス層を経由している - [ ] 戻り値は生のレコードではなく、明示的に組み立てた最小限のオブジェクトである
- [ ] エラーメッセージから、他ユーザーのIDやメールアドレスの存在が分からない
- [ ] インラインのクロージャが秘密情報や非公開データを捕捉していない
- [ ] 複数インスタンスでセルフホストする場合、固定の
NEXT_SERVER_ACTIONS_ENCRYPTION_KEYをシークレットとして共有している - [ ]
allowedOriginsは未設定か、実際に使う公開ホストだけに限定している - [ ]
bodySizeLimitはアップロードに必要な場合だけ引き上げている - [ ] コストが高い、または濫用されやすいアクションにレート制限がある
- [ ] 破壊的なアクションで、より強い確認や再認証を求めている
- [ ] 未ログイン・非所有者・余分な項目付きのリクエストで、アクションを直接呼び出すテストがある
- [ ] デプロイ後の「Failed to find Server Action」に対し、UIに再試行の導線がある
FAQ
1. アクションIDが暗号化されているなら、アクションごとの認証チェックは不要では?
必要です。公式ドキュメントは、暗号化IDは認証の実装漏れがあった場合のリスクを下げるものとしたうえで、アクションは直接POSTで到達できるものとして扱い、各アクション内で認証と認可を確認するよう求めています。正規のユーザーのクライアントにはアクションIDが届くため、IDは秘密ではありません。
2. Server ActionsのCSRF対策だけで十分?
守れるのは、別サイトからフォームを送信させるという特定のブラウザ攻撃です。アクションはPOSTしか受け付けず、Origin のホストがアプリのホストと一致しないリクエストは拒否されます。一方で、Origin ヘッダーが無いリクエストは警告付きで通過し、ログイン済みの攻撃者ならCSRFを使う必要すらありません。セッションCookieの SameSite 設定を維持し、すべてのアクションで認可を行ってください。
3. 機密性の高い操作はServer ActionsとRoute Handlersのどちらで書くべき?
どちらにも同じチェックが必要で、Next.jsの認証ガイドは両方とも外部公開のエンドポイントとして扱うよう求めています。自分のUIから起動する更新処理ならServer Actions、外部クライアントやWebhook、更新以外のリクエストのために固定URLとHTTPの意味づけが必要ならRoute Handlersを選びます。
4. Proxyに認証チェックを置けば、全アクションをまとめて守れる?
Proxyは、セッションCookieに基づく楽観的なリダイレクトに使うもので、唯一の防御にはしません。認証ガイドは、Proxyを唯一の防御線にすべきではなく、セキュリティチェックの大半はデータソースの近くで行うべきだとしています。
参考資料
- How to think about data security in Next.js(Next.js Docs)
- Server Actions and Mutations(Next.js Docs)
- serverActions configuration(Next.js Docs)
- How to implement authentication in Next.js(Next.js Docs)
- Insecure Direct Object Reference Prevention Cheat Sheet(OWASP)
- Mass Assignment Cheat Sheet(OWASP)
内部リンク
免責事項
一般的なエンジニアリング・セキュリティ上の情報提供であり、個別の保証をするものではありません。フレームワークの挙動はバージョンによって変わります。利用中のNext.jsのバージョン、認証ライブラリ、デプロイ環境に照らして詳細を確認し、自分のアクションをテストしてから運用してください。