みなさんこんにちは、ヒロポンです!
「このフォームの POST、CSRF 対策入ってます??」
社内アプリのコードレビューで、こう聞かれて固まったこと、ないですか。
名前だけは知ってる。でも、CSRF がそもそも何を防いでるのか、なんでトークン1個で防げるのか、って聞かれると答えられない。私も昔まさにこれでした。
今日は ASP.NET MVC の CSRF 対策を、攻撃の仕組みから積み上げて理解していきます。@Html.AntiForgeryToken() を書く意味が腑に落ちれば、レビュー指摘にもサッと返せるし、Ajax で詰まった時も自力で抜けられる。MVC5(.NET Framework)のコード前提で進めます。
そもそも CSRF は何を悪用する攻撃なのか
CSRF(クロスサイト・リクエスト・フォージェリ)は、ログイン済みの利用者になりすまして、勝手に操作を実行させる攻撃です。
肝は「ブラウザは Cookie を自動で送る」という Web の仕様。
利用者が社内アプリにログインすると、認証 Cookie がブラウザに入ります。以降、そのサイト宛のリクエストには、ブラウザが自動で Cookie をくっつけて送ってくれる。ログイン状態が維持されるのは、このおかげなんですよね。
ところが。この「自動でくっつく」性質が、そのまま裏目に出る。
攻撃者の罠ページから社内アプリ宛にフォーム POST を飛ばしても、ブラウザは律儀に認証 Cookie を付けてしまう。サーバから見ると「ログイン済みの本人からのリクエスト」に見える。ここが穴です。
ここまでで分かったこと: CSRF は Cookie が自動送信される仕様を悪用する。だから「Cookie が付いてる=本人」という判定だけだと防げない。
トークンが無い世界: なぜ罠ページで乗っ取られるのか
具体的な流れを図にするとこうです。

罠ページには、こんな感じの隠しフォームが仕込まれています。
<!-- 攻撃者サイトに置かれた罠。ページを開いた瞬間に自動送信される -->
<form action="https://社内アプリ/Invoice/Delete" method="post" id="evil">
<input type="hidden" name="id" value="1001" />
</form>
<script>document.getElementById('evil').submit();</script>
利用者は「変なページ踏んだな」くらいの感覚。でも裏では、社内アプリの削除処理が本人名義でしっかり走ってる。これが CSRF です。
サーバ側は Cookie しか見ていない。だから正規フォームからの POST なのか、罠ページからの POST なのか、区別できない。判別する手がかりを持ってないんです。
ここまでで分かったこと: サーバは「このリクエスト、うちのフォームから来たやつ?」を判別できていない。CSRF 対策とは、その判別手段を1つ足すこと。
AntiForgeryToken の基本: 何を「合言葉」にするのか
そこで登場するのが AntiForgeryToken です。
考え方はシンプル。攻撃者が知りようのない「合言葉」を、正規フォームだけに埋めておく。POST の時にその合言葉が付いてなければ弾く、という仕組みです。
ASP.NET MVC では、この合言葉を2つに分けて持ちます。
- フォームの隠しフィールド(
__RequestVerificationToken)… HTML の中に埋め込まれる - Cookie(
__RequestVerificationToken)… ブラウザに保存される
正規のページを開いた人だけが、この両方を持ってる状態になる。
ん?Cookie 1枚だけじゃダメなの?? って思いますよね。ダメなんです。理由はこう。
攻撃者の罠ページは、社内アプリの HTML を読めない(同一オリジンポリシーで守られてる)。だから隠しフィールドの値を知りようがない。「Cookie は自動で送れても、隠しフィールドの値までは揃えられない」んです。ここで攻撃が成立しなくなる。
ここまでで分かったこと: トークンは「Cookie」と「フォームの隠しフィールド」の2枚セット。攻撃者は後者を読めないので偽装できない。
仕組み: なぜ2枚セットだと防げるのか
サーバは POST を受け取ったとき、この2枚を突き合わせて検証します。
- Cookie のトークンと、フォームのトークンがペアとして正しいか
- どちらか片方でも欠けていたら、
HttpAntiForgeryExceptionを投げて処理を止める
罠ページからの POST は、Cookie は付いてくる(自動送信される)。でも、フォームのトークンが無い。あってもでたらめ。だから検証で落ちる。
正規フォームからの POST は、2枚とも正しく揃ってるので通る。
「読めない値を1枚まぜておく」。たったこれだけで、Cookie の自動送信という穴を塞げる。ほんとよく出来てます!!
ここまでで分かったこと: 検証=Cookie とフォームのトークンのペア照合。片方だけでは通らないのがミソ。
実装: @Html.AntiForgeryToken() と [ValidateAntiForgeryToken]
仕組みが分かれば、コードは2箇所だけ。
まず View 側。フォームの中に @Html.AntiForgeryToken() を1行入れます。これで隠しフィールドと Cookie の両方が発行される。
@using (Html.BeginForm("Save", "Invoice", FormMethod.Post))
{
@Html.AntiForgeryToken()
<input type="text" name="amount" />
<button type="submit">保存</button>
}
次に Controller 側。受け取るアクションに [ValidateAntiForgeryToken] を付けます。
using System.Web.Mvc;
public class InvoiceController : Controller
{
[HttpPost]
[ValidateAntiForgeryToken]
public ActionResult Save(string amount)
{
// ここに到達した時点で、トークン検証は通過済み
// 正規フォームからの POST であることが保証されている
SaveInvoice(amount);
return RedirectToAction("Index");
}
}
これだけ。View で埋めて、Controller で検証する。いい感じに2点セットで効きます。
ここまでで分かったこと: 実装は「View で埋める」「Controller で検証する」の2箇所。片方だけだと動かない。
ここで業務SEがよく踏む落とし穴を2つ。
① 片方だけ付けて検証例外になる
これが一番多い。[ValidateAntiForgeryToken] は付けたのに、View の @Html.AntiForgeryToken() を書き忘れる。あるいはその逆。すると POST した瞬間に HttpAntiForgeryException(既定で HTTP 500)が飛んできます。
「保存押したら 500 エラー」で焦るやつ。犯人はトークンの片割れ欠けなので、View と Controller の両方に入ってるか、まずそこを疑ってください。
② 部分ビューやモーダルでトークンが埋まっていない
フォームを部分ビューやモーダルで動的に差し込むと、@Html.AntiForgeryToken() の行が抜けがち。画面上はフォームが見えてるのに、トークンだけ無い。この状態でも①と同じ例外になります。
Ajax の POST でトークンをどう送るか
さて、ここが本題の詰まりどころ。Ajax の POST は、フォーム送信じゃないのでトークンが自動で乗りません。
@Html.AntiForgeryToken() で隠しフィールド自体は HTML に存在する。でも、fetch や jQuery.ajax でボディを組み立てると、その隠しフィールドは勝手には入ってこない。結果、サーバで検証例外。ここでハマる人、めちゃくちゃ多いんですよね。
やり方は2つあります。
やり方A: フォームのフィールド名で一緒に送る(手軽)
[ValidateAntiForgeryToken] は、既定でフォームボディの __RequestVerificationToken を見にいきます。なので、隠しフィールドの値を読んで、同じ名前で POST データに混ぜてやれば通る。
// 隠しフィールドからトークンを読む
var token = document.querySelector('input[name="__RequestVerificationToken"]').value;
fetch('/Invoice/Save', {
method: 'POST',
body: new URLSearchParams({
__RequestVerificationToken: token, // これを混ぜるのがキモ
amount: '1000'
})
});
サーバ側は [ValidateAntiForgeryToken] のままで OK。URLSearchParams で送ると中身がフォーム形式(application/x-www-form-urlencoded)になるので、既定の検証がそのまま効くんです。追加のコードは要りません。
逆に、ボディを JSON で投げたい時はこの手が使えない。その場合が次の B です。
やり方B: ヘッダで送る(JSON を投げたい時)
ボディを JSON にしたいなら、フォームフィールドが使えないので、トークンをヘッダに載せます。ただし [ValidateAntiForgeryToken] はヘッダを見てくれない。なので、サーバ側にヘッダを読む検証を自作します。
using System.Web.Helpers;
using System.Web.Mvc;
// ヘッダからトークンを読んで検証する属性
public class ValidateHeaderAntiForgeryTokenAttribute
: FilterAttribute, IAuthorizationFilter
{
public void OnAuthorization(AuthorizationContext context)
{
var request = context.HttpContext.Request;
var cookie = request.Cookies[AntiForgeryConfig.CookieName];
// Cookie 側と ヘッダ側のトークンをペアで検証
AntiForgery.Validate(
cookie != null ? cookie.Value : null,
request.Headers["RequestVerificationToken"]);
}
}
JavaScript 側は、こんな感じでヘッダに載せるだけ。
var token = document.querySelector('input[name="__RequestVerificationToken"]').value;
fetch('/Invoice/Save', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'RequestVerificationToken': token // ヘッダで別送
},
body: JSON.stringify({ amount: 1000 })
});
Controller では、さっき自作した属性のほうを付けます。
[HttpPost]
[ValidateHeaderAntiForgeryToken] // ← ヘッダ検証版
public ActionResult Save(InvoiceModel model)
{
SaveInvoice(model);
return Json(new { ok = true });
}
迷ったら、まずやり方A(フォームフィールドで送る)で十分です。JSON をどうしても投げたい時だけ B を使う。そのくらいの温度感で大丈夫。
ここまでで分かったこと: Ajax はトークンが自動で乗らない。フォームフィールドに混ぜる(A)か、ヘッダ+自作検証(B)で明示的に送る。
⚠ 動作確認メモ: この記事の Controller / 属性コードは構文とロジックのレビューまで(ASP.NET のランタイム実行は Windows + IIS 前提のため)。実環境(Visual Studio + IIS Express)での通し動作は、お手元でも確認してください。
GET と POST の使い分け: なぜ GET で更新してはいけないか
CSRF 対策の大前提として、副作用のある操作は POST にする、というルールがあります。
削除・保存・送金みたいな「状態を変える操作」を GET(例: /Invoice/Delete?id=1001)で書いてしまうと、<img src="/Invoice/Delete?id=1001"> を罠ページに1行貼るだけで発火する。トークンを埋める隙間すら無い。
だから、こう切り分けます。
- GET … 一覧表示・検索など「読むだけ」の操作
- POST … 保存・更新・削除など「変える」操作+ AntiForgeryToken
この切り分けが土台になる。裏を返すと、GET で更新処理を書いてる時点で、トークン以前の設計問題なんですよね。
ここまでで分かったこと: トークンは POST を守る道具。まず「変える操作は POST」に揃えるのが前提。
MVC5 と ASP.NET Core で書き方はどう違うか
.NET Framework 世代の現場だと MVC5 ですが、Core に移ると書き方が少し変わります。並べておきます。

考え方(Cookie とトークンのペア照合)は、どっちも同じ。Core は「自動でやってくれる範囲が広い」だけ、と捉えておけば移行時に迷いません。
ここまでで分かったこと: 仕組みは共通。Core は自動化が進んでいるが、守っている本質は MVC5 と同じ。
現場ではこう入れている
私がフリーランスで入った社内向けアプリの改修で、セキュリティ診断に出したら「フォーム POST に CSRF 対策なし」で全面的に指摘が返ってきたことがあります。
やったのは地味な作業。フォームを持つ View を片っ端から開いて @Html.AntiForgeryToken() を入れ、対応する POST アクションに [ValidateAntiForgeryToken] を足して回っただけ。
ただ、Ajax で保存してる画面が何枚かあって、そこだけ検証例外が出て詰まりました。犯人はさっきの「Ajax はトークンが自動で乗らない」やつ。フォームフィールドにトークンを混ぜる方式(やり方A)に直して、ようやく診断が通った。
このとき効いたのが、仕組みを先に理解してたこと。「トークンは2枚セット、Ajax は片方を明示的に送る」が頭に入ってれば、例外を見た瞬間に原因が読める。逆に丸暗記だと、500 エラーの前でただ固まるんですよね。
セキュリティ指摘って精神的にキツいんですけど、CSRF は定石が決まってるので、仕組みさえ掴めば怖くないです。
まとめ
CSRF 対策は、仕組みで理解すると一本の線でつながります。
- CSRF は Cookie の自動送信を悪用して、本人になりすます攻撃
- AntiForgeryToken は「Cookie」と「フォームの隠しフィールド」の2枚セットの合言葉。攻撃者は後者を読めないので偽装できない
- 実装は View に
@Html.AntiForgeryToken()、Controller に[ValidateAntiForgeryToken]の2箇所 - Ajax はトークンが自動で乗らないので、フォームフィールドに混ぜるかヘッダで別送する
- 前提として、状態を変える操作は POST に寄せる
「なんとなくトークン付けとく」から「なぜ2枚セットなのか説明できる」に変わると、レビュー指摘にも即答できるし、社内アプリのセキュリティ監査も一段通りやすくなります。
よくある質問
AntiForgeryToken を付けたのに検証例外(HttpAntiForgeryException)が出るのはなぜですか?
View の @Html.AntiForgeryToken() を書き忘れているか、逆に Controller の [ValidateAntiForgeryToken] だけ付いていてトークンが送られていないケースが大半です。トークンは「フォームの隠しフィールド」と「Cookie」の両方が揃って初めて検証を通ります。片方が欠けると例外になります。部分ビューやモーダルで埋め忘れるのもよくあるパターンです。
Ajax の POST でトークンはどう送ればいいですか?
Ajax はフォーム送信ではないので、トークンが自動では乗りません。隠しフィールド __RequestVerificationToken の値を JavaScript で読み取り、POST データに同名で含めるのが一番手軽です。JSON ボディで送りたい時はヘッダに載せますが、その場合はサーバ側でヘッダを読んで検証する属性を自作する必要があります。
GET リクエストには CSRF 対策は要りませんか?
GET で状態を変えない設計なら不要です。CSRF 対策の大前提は「副作用のある操作(保存・削除・送金)は POST にする」ことです。GET で更新処理を書くと、img タグ1行で発火してしまい、そもそもトークンで守れません。
ASP.NET Core でも書き方は同じですか?
考え方(Cookie とトークンのペア照合)は同じです。Core ではフォームタグヘルパーがトークンを自動で埋め込み、[AutoValidateAntiforgeryToken] で全 POST を一括検証できます。MVC5 は @Html.AntiForgeryToken() と [ValidateAntiForgeryToken] を手動で付ける点が違います。
次に読むべき記事



以上!
同じ「レビューで CSRF 指摘されて固まった」人がいたら、この記事シェアしてくれると嬉しいです!!
執筆者
バイブス父さん — 業務 SE 7 年(SIer 正社員 2 / フリーランス 5)。現職は SEO 直轄部の AI アドバイザー兼 PL、副業で中小 SIer の CTO。SIer の正社員からフリーランスに転じ、複数のエージェント経由で案件を回してきた経験ベースで「業務 SE 視点」の技術 + キャリア記事を書いています。
🐦 X: @hiro_progra0524(日々の現場メモ更新中)
📝 About Me で経歴詳細を見る


