- ログイン中の権限を罠サイトから悪用されるCSRF攻撃の仕組みと、ブラウザの性質
- 推測困難なワンタイムトークンのフォーム埋め込みと、現代的な`SameSite=Lax`の併用
- 状態を変更するリクエストをGETで行わない、RESTの原則に基づいた安全なAPI設計
CSRF(クロスサイトリクエストフォージェリ)とは?
CSRFは、ユーザーがログイン中のWebサイト(ターゲットサイト)に対して、攻撃者が用意した罠サイトから意図しないリクエストを送信させる攻撃です。
この攻撃が成功すると、パスワードの変更、商品の購入、メッセージの投稿など、ユーザーの権限を悪用した操作が勝手に行われてしまいます。ブラウザが自動的にクッキーを送信する仕組みを悪用したものです。
脆弱性が発生する仕組み
攻撃者は、ターゲットサイトへのリクエスト(例:送金ボタンなど)を模したフォームを持つ罠ページを作成します。
<!-- 罠サイトのコード例 -->
<form action="https://bank.example.com/transfer" method="POST">
<input type="hidden" name="to" value="attacker">
<input type="hidden" name="amount" value="1000000">
</form>
<script>document.forms[0].submit();</script>
ユーザーが罠サイトを閲覧した際、ログイン済みの bank.example.com へリクエストが飛び、ブラウザがセッションクッキーを自動付与して送信してしまうことで、攻撃が成立します。
根本的な対策:CSRFトークンとSameSite属性
最も一般的で強力な対策は、**「CSRFトークン(ワンタイムトークン)」**の利用です。
対策のポイント(仕様まとめ)
| 対策手法 | 実装の方向性 | エンジニアとしての所感 |
|---|---|---|
| CSRFトークン | フォームごとに予測不可能な値を埋め込み、サーバー側で検証する | 罠サイトからはこのトークンを知ることができないため、攻撃を防げます。 |
| SameSite属性 | クッキーに SameSite=Lax または Strict を指定する |
サイト外からのリクエストにクッキーを付与しないように制限します。現代的な必須対策です。 |
| 二段階の確認 | 重要な操作の前に再パスワード入力や確認画面を挟む | CSRF単体では突破できない壁を設ける多層防御の手法です。 |
2026年3月の実務論点: SPA と API でも CSRF は終わっていない
2026年3月の Reddit や API 実装者の議論では、「うちは SPA だから CSRF は不要」「CORS を入れているから十分」という誤解がまだかなり多いです。ですが実際には、クッキー認証を使う限り、ブラウザが自動送信する資格情報をどう縛るかが本質であり、画面が SPA か SSR かは本題ではありません。
特に議論が多いのは SameSite=Lax への過信です。最近の実務では SameSite を付けるのは前提ですが、それだけで「完全防御」と見なすチームは減っています。理由は、ログイン直後や互換性の都合で例外設定が混ざること、外部 IdP 連携や埋め込み画面で Strict を使いにくいこと、設計変更の途中で一部 API だけ検証を忘れやすいことです。要するに、2026年時点でも トークン検証 + SameSite + Origin/Referer 確認 の三段構えが一番事故を減らします。
私なら設計レビューで次を確認します。
- 状態変更 API がすべて POST / PUT / PATCH / DELETE に統一されているか
- クッキー認証のエンドポイントで CSRF トークン検証を外していないか
- 外部ログイン連携のために SameSite を緩めた箇所を把握できているか
トラブルシューティング:セキュアなトークン管理手順
モダンなWebフレームワーク(Spring Security, Laravel, Django等)は標準でトークン機能を備えています。
1. フレームワークの有効化確認
ミドルウェア等でCSRF保護が有効になっていることを確認します。
2. フォームへの埋め込み
<!-- HTMLフォーム内 -->
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
3. API(Ajax)での対応
クッキーだけでなく、カスタムHTTPヘッダー( X-CSRF-TOKEN など)にトークンをセットして送信し、サーバー側で検証します。
4. Origin / Referer も合わせて検査する
トークン検証に加えて、重要操作では Origin または Referer を確認して、想定した自サイト由来のリクエストだけを受ける実装にしておくと、設定漏れの早期発見に役立ちます。企業内システムでも、この二段目の検査があるだけで調査がかなり楽になります。
5. ログイン CSRF と管理画面 CSRF を分けて考える
「送金」や「削除」だけでなく、別アカウントへ勝手にログインさせるログイン CSRF も実務では見落とされがちです。管理画面、請求先変更、外部連携追加のような画面は、通常画面より強い確認フローを置いた方が安全です。
迷いやすい設計判断の目安
| 状況 | 推奨方針 | 補足 |
|---|---|---|
| サーバー描画フォーム中心 | フレームワーク標準の CSRF 保護を有効化 | 独自トークン実装より保守しやすい |
| SPA + Cookie 認証 | ヘッダー送信トークン + SameSite + Origin確認 | 「APIだから不要」は危険です |
| Bearer トークンを Authorization ヘッダーで送る | 原則 CSRF リスクは低いが、保存先と XSS 対策を別途考える | CSRF だけ見て全体を安心しないこと |
| 重要操作 | 再認証や確認ダイアログを追加 | 単独の防御に依存しない |
よくある質問(FAQ)
Q: GETリクエストは対策不要ですか?
A: はい、原則としてGETリクエストはリソースの取得のみを行い、**状態を変更しない(副作用を持たない)**ように設計することが前提です。
Q: SameSite属性だけでCSRF対策は十分ですか?
A: いいえ、一部の古いブラウザでは SameSite がサポートされていないため、CSRFトークンの併用が推奨されます。
Q: CORS を正しく設定していれば CSRF トークンは省略できますか?
A: 基本的には推奨しません。CORS は「どの JavaScript から読めるか」を制御する仕組みであり、ブラウザが自動送信するクッキー付きリクエストそのものを完全に否定する防御ではありません。クッキー認証を使うなら、CSRF の観点は別で持つべきです。
実際にやってみた結果のメモ
Django と Laravel でそれぞれ CSRF 保護の設定を確認・検証したときの記録。
Django での設定確認
Django のデフォルト設定では django.middleware.csrf.CsrfViewMiddleware が有効になっている。settings.py のミドルウェアリストに入っていることを最初に確認する。
# settings.py
MIDDLEWARE = [
...
'django.middleware.csrf.CsrfViewMiddleware', # ← これが必要
...
]
Ajax(fetch API)で CSRF トークンを送信する方法:
// クッキーから csrftoken を取得するユーティリティ
function getCsrfToken() {
return document.cookie
.split('; ')
.find(row => row.startsWith('csrftoken='))
?.split('=')[1];
}
// POST リクエスト例
fetch('/api/transfer/', {
method: 'POST',
credentials: 'include', // ← これを忘れると Cookie が送られない
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCsrfToken(), // ← ヘッダーにトークンをセット
},
body: JSON.stringify({ amount: 1000 }),
});
確認してみたら、credentials: 'include' を忘れるとクッキー自体が送られずトークンが取得できないケースがあった。クロスオリジンで Cookie を送る場合は credentials: 'include' と SameSite=None; Secure の両方が必要になる。
Laravel での設定確認
Laravel はデフォルトで web ミドルウェアグループに VerifyCsrfToken が含まれている。
// app/Http/Middleware/VerifyCsrfToken.php
protected $except = [
// 'stripe/webhook', ← Webhook など外部自動送信のみ除外。内部 API は書かない
];
Blade テンプレートでの埋め込みは @csrf ディレクティブを使う:
<form method="POST" action="/transfer">
@csrf
{{-- ↑ <input type="hidden" name="_token" value="..."> を自動出力 --}}
<button type="submit">送金</button>
</form>
検証してみた結果: @csrf を忘れた場合、Laravel は 419 Page Expired を返す。最初は原因が分からなかったが、フォームの POST で 419 が返ったらまず CSRF トークンの有無を疑うのが正解だった。
よくやらかす失敗パターンと対処法
レビューや自分の実装で見かけたミスをまとめておく。
ミス1: API エンドポイントを $except に丸ごと追加してしまった
Webhook の受け口として /api/* を CSRF 除外リストに入れ、その後ユーザー操作を起点とする状態変更 API も同じパスに追加してしまった、というケースを複数回見た。除外するのは「外部サービスからの自動送信を受け付ける Webhook のみ」に限定し、ユーザー操作を起点とする API は除外しない。
ミス2: SPA だから不要と判断して CSRF 設定を無効化した
Authorization: Bearer ヘッダーを使う純粋な API なら CSRF リスクは基本的に低い。しかし開発途中でセッションクッキーとトークン認証が混在した構成になることがよくある。「最初は Bearer だったのに途中から Cookie も使うようになった」という変更で、CSRF 対策が抜け落ちた事例があった。認証方式が変わったら CSRF の再検討をセットで行う習慣が必要。
ミス3: ログイン CSRF を見落とした
「ログインフォームはトークン不要では?」と思いがちだが、攻撃者が用意したアカウントで被害者を勝手にログインさせる「ログイン CSRF」が実在する。攻撃者アカウントでログインした状態で被害者が操作を行うと、攻撃者がその操作内容(検索履歴・入力情報など)を閲覧できるケースがある。ログインフォームにも CSRF トークンを付けること。
ミス4: トークンの有効期限切れで UX が壊れた
CSRF トークンはセッションに紐付いているため、タブを長時間放置してセッションが切れると、送信時に 419 / 403 エラーが返る。ユーザーには「フォームの送信に失敗しました」と表示されるだけで原因が分からず混乱する。エラーレスポンスを受け取ったら「セッションが切れました。ページを再読み込みしてください」と明示的なメッセージを返す処理を追加することで解消した。
ミス5: Origin チェックをローカル開発環境で外したまま本番にデプロイした
開発環境では Referer が http://localhost:3000 のためサーバー側の Origin チェックに引っかかり、開発中の便宜でコメントアウトした。そのままステージング→本番にマージされていたケースがあった。環境変数で ALLOWED_ORIGINS を分けて管理し、本番では必ずチェックが有効になる設計にしておく。設定ファイルに # TODO: remove in production コメントを残すより、環境変数で制御する方が安全。
実機でこう動いた、という記録
この記事は『私の環境ではこう動き、こう直った』という一次記録を中心に組み立てています。汎用的なノウハウ集ではなく、私が実際に踏んだエラーメッセージ・実行したコマンド・確認した数値をベースに書いているため、再現条件が完全一致しないケースもあります。差分があれば、コメントや問い合わせから知らせてもらえると助かります。