動くコード図鑑技術記事現場の渡り方キャリア論すべての記事About
技術記事

ASP.NET MVC の CSRF 対策 — AntiForgeryToken でトークンを埋める仕組みと Ajax でハマる所

バイブス父さん
現役の業務SE
2026年9月12日17 min read広告 (PR) を含む場合があります
ASP.NET MVC の CSRF 対策 — AntiForgeryToken でトークンを埋める仕組みと Ajax でハマる所

みなさんこんにちは、ヒロポンです!

「このフォームの 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 が付いてる=本人」という判定だけだと防げない。

トークンが無い世界: なぜ罠ページで乗っ取られるのか

具体的な流れを図にするとこうです。

CSRF攻撃の流れ。利用者が社内アプリにログインして認証Cookieを持った状態で攻撃サイトの罠ページを踏むと、隠しフォームが自動POSTされ、ブラウザが認証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 に移ると書き方が少し変わります。並べておきます。

ASP.NET MVC5 と ASP.NET Core の CSRF 対策の違い。トークン埋め込み・全POST一括検証・既定ヘッダ名・自動化の度合いを比較した表

考え方(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 で経歴詳細を見る


この記事のコードと手順は ぜんぶ動作検証済み。 安心して現場で試してくれ。
バイブス父さん

現役の業務SE。C# / SQL Server 保守の現場から、コードも人もキャリアも全部書く。 実体験ベース。

運営者について