CSRFトークンとは?仕組み・Double Submit CookieとSPAでの実装

CSRFトークンは、ログイン中の利用者になりすました偽のリクエストを、サーバー側で機械的に見分けるための値です。しくみ自体は「サーバーが発行した推測不能な値が付いていなければ拒否する」だけですが、実装で迷うのはその先にあります。トークンをどこに置くのか、セッションを持たないAPIではどう検証するのか、JWT認証なら不要になるのか。2つの標準的な実装パターンの違いと選び分けを、公式ドキュメントと動作するコードで整理します。

まとめ

  • 選び分けの軸は、トークンの正解をサーバー側に保存する必要があるかどうか。保存できるならSynchronizer Token Pattern、避けたいならSigned Double Submit Cookieです。値の一致だけを見る素のDouble SubmitはOWASPが非推奨としています。
  • Double Submit構成では、トークンのCookieにHttpOnlyを付けられません。JavaScriptが読む方式である以上、原理的に矛盾します。
  • JWTをCookieに保存した時点でCSRF対策が必要です。分かれ目は認証方式ではなく保存場所にあります。
  • CORSはCSRF対策になりません。フォームからのPOSTはそもそもCORSの適用対象外として送信されるためです。
  • SameSite CookieとSec-Fetch-Siteは多層防御であって、トークン検証の代替ではありません。ただしLaravel 13はSec-Fetch-Siteによるオリジン検証を第一層に据える構成へ移行しています。

以下、それぞれの根拠と実装を見ていきます。

CSRFトークンが防いでいるもの|Cookieの自動送信という前提

CSRFが成立するのは、ブラウザが「そのCookieを設定したサイト宛のリクエストであれば、どこのページから発火したものでも自動的にCookieを添付する」という挙動を持つためです。攻撃者のページに置かれたフォームが自動送信されると、被害者のログインCookieが付いた状態でリクエストが届き、サーバーからは正規の操作と区別がつきません。攻撃の類型やXSSとの差はXSSとCSRFの違いとは?仕組み・被害・対策を実装レベルで徹底比較で扱っています。

CSRFトークンは、この「区別がつかない」状態を壊すための値です。同一オリジンポリシーにより、攻撃者のページからは別オリジンのレスポンスの中身を読めません。つまり被害者のセッション固有のトークン値を知り得ないため、その値をリクエストに含めることを必須にすれば、攻撃者のリクエストだけが弾かれます。

IPAの「安全なウェブサイトの作り方」1.6節は、根本的解決を3点に限定しています。処理実行ページをPOSTにしてhiddenパラメータの秘密情報を照合する方法(6-(i)-a)、実行直前のページでパスワードの再入力を求める方法(6-(i)-b)、Refererが正しいリンク元かを確認する方法(6-(i)-c)です。保険的対策は6-(ii)の重要操作のメール通知1点だけで、IPA自身が事後処理であり攻撃自体は防げないと明記しています。トークン検証は根本的解決の側にある、という位置づけです。

Synchronizer TokenとDouble Submit Cookieの選び分け

OWASPのCSRF Prevention Cheat Sheetは、トークン方式としてSynchronizer Token Pattern、推奨版のSigned Double-Submit Cookie、非推奨のNaive Double-Submit Cookieの3種を挙げ、さらにFetch Metadataヘッダー、AJAX/API向けカスタムリクエストヘッダー、SameSiteやOrigin検証といった多層防御を並べています。トークン方式の分岐点は、トークンの正解をサーバー側に保存する必要があるかどうかにあります。

観点 Synchronizer Token Signed Double Submit
正解の持ち方 セッションに保存 署名から導出
トークンの保存 必要 不要
必要なもの セッションストア 秘密鍵とセッション依存値
水平スケール セッション共有が前提 秘密鍵の共有のみ
Djangoでの切替設定 CSRF_USE_SESSIONS=True CSRF_USE_SESSIONS=False

DjangoのCSRF_USE_SESSIONSは既定がFalseで、Double Submit側に倒れた設計。セッションに寄せたい場合だけ明示的に切り替えます。

サーバー側セッションに正解を持つSynchronizer Token Pattern

Synchronizer Token Patternでは、サーバーがトークンを生成してセッションに保存し、同じ値をフォームのhidden項目として埋め込みます。OWASPは「CSRF tokens should be generated on the server-side and they should be generated only once per user session or each request」として、セッションごとかリクエストごとかのいずれかを示しています。ただし同じチートシートは、リクエストごとの更新のように頻繁すぎる再発行を「a misconception」とも書いており、実用上はセッション単位で足ります。

Laravelはこの方式です。@csrf Bladeディレクティブが_tokenという名前のhidden項目を出力し、ミドルウェアがセッション内の値と突き合わせます。トークンはセッション再生成のたびに変わるため、攻撃側のアプリケーションからは到達できません。

素のDouble Submit Cookieが破られる条件

Double Submit Cookieは、同じ値をCookieとリクエストの両方に載せ、サーバーは両者の一致を見ます。トークン自体を保存しなくてよいため、分散構成と相性の良い方式です。

ただし値の一致しか見ない素の実装(Naive Double-Submit Cookie)を、OWASPはDISCOURAGEDと分類しています。対象ドメインにCookieを書き込める攻撃者によって「bypassable」であるとし、経路として脆弱な兄弟サブドメイン、DNSの乗っ取り、__Host-接頭辞が付いていないCookieへの平文HTTP経由のCookie注入を挙げています。攻撃者が自分の知る値をCookieとリクエストの両方にセットできれば、一致は簡単に作れるからです。照合対象は「custom request header or form parameter ONLY」で、Cookie同士の突き合わせは「INSECURE」とも明記されています。

ここで効くのが__Host-接頭辞。MDNによれば、この接頭辞の付いたCookieはHTTPSページからSecure属性付きで設定され、Domain属性を持たず、Path属性が/であることをすべて満たす必要があります。Domain属性を書けない制約が、サブドメインからの上書きを構造的に封じます。

セッションに結び付けるSigned Double Submit Cookieの実装

OWASPがDouble Submit方式の中で最も安全な実装として挙げるのが、トークンを認証済みセッションに明示的に紐付けるSigned Double-Submit Cookie(RECOMMENDED)です。単に署名するだけでセッションに束ねない実装は「provides minimal protection」とされ、Cookie注入に対して脆弱なままだと釘を刺されています。必要なのは、ログインセッションごとに変わるセッション依存値、サーバー側の秘密鍵、衝突回避用のランダム値の3つ。セッション依存値はサーバー側セッションIDのほか、JWTを発行するたびに変わるランダム値でも構いません。トークンそのものを保存しない点が、Synchronizer Tokenとの違いです。

const crypto = require('crypto');

// サーバだけが持つ秘密鍵(環境変数などから読み込む)
const SECRET = crypto.randomBytes(32);

// セッション依存値に結びつけたトークンを作る
function issueToken(sessionId) {
  const nonce = crypto.randomBytes(16).toString('base64url');
  const mac = crypto.createHmac('sha256', SECRET)
    .update(`${sessionId}.${nonce}`)
    .digest('base64url');
  return `${nonce}.${mac}`;
}

// 受け取ったトークンが、そのセッションのものか検証する
function verifyToken(sessionId, token) {
  if (typeof token !== 'string') return false;
  const [nonce, mac] = token.split('.');
  if (!nonce || !mac) return false;
  const expected = crypto.createHmac('sha256', SECRET)
    .update(`${sessionId}.${nonce}`)
    .digest('base64url');
  const a = Buffer.from(mac);
  const b = Buffer.from(expected);
  if (a.length !== b.length) return false;
  return crypto.timingSafeEqual(a, b);
}

const token = issueToken('session-abc');
console.log('正規セッションでの検証:', verifyToken('session-abc', token));
console.log('別セッションが同じトークンを使った場合:', verifyToken('session-xyz', token));
console.log('攻撃者が値を捏造した場合:', verifyToken('session-abc', 'aaaa.bbbb'));

Node.js v26.5.0で実行すると、1行目がtrue、2行目と3行目がfalseになります。要点は2行目。攻撃者が別のセッションで正規に発行されたトークンを盗んで使い回しても通りません。nonceが衝突回避用のランダム値、SECRETが秘密鍵にあたります。比較にtimingSafeEqualを使うのは、通常の文字列比較が不一致の位置で打ち切られ、処理時間から正解を推測されうるためです。

Double Submit CookieにHttpOnlyを付けられない理由

「トークンのCookieにHttpOnly属性を付ければ、JavaScriptから読めなくなって安全になる」という説明を見かけます。しかしDouble Submit Cookie方式では、これは実装として成り立ちません。JavaScriptがCookieを読んでリクエストヘッダーに載せることで初めて機能する方式だからです。

フレームワーク Cookie名 ヘッダー名 HttpOnly
Django csrftoken X-CSRFToken 既定 False
Spring Security XSRF-TOKEN X-XSRF-TOKEN withHttpOnlyFalse()
Laravel XSRF-TOKEN X-XSRF-TOKEN false固定

Laravelのfalseは設定項目ではありません。PreventRequestForgeryXSRF-TOKENを組み立てる際にHttpOnlyへfalseを直接渡しており、config/session.phphttp_onlyはセッションCookie側にしか効きません。

DjangoのCSRF_COOKIE_HTTPONLYは既定でFalse。公式ドキュメントは「Designating the CSRF cookie as HttpOnly doesn’t offer any practical protection because CSRF is only to protect against cross-domain attacks」と述べ、JavaScriptでCookieを読める攻撃者はブラウザから見て既に同一ドメイン上にいるため意味がないとしています。そのうえで「Although the setting offers little practical benefit, it’s sometimes required by security auditors」と、実用上の利点はほとんどないとしつつ監査要件で求められる場合に触れ、有効化するならAJAXではhidden項目から値を取得するよう指示しています。設定自体は可能で、その代わり読み取り経路を変える必要がある、ということです。

Spring Securityも同じ構造。CookieCsrfTokenRepositoryXSRF-TOKENに書き込み、X-XSRF-TOKENヘッダーまたは_csrfパラメータから読み取ります。公式ドキュメントはwithHttpOnlyFalse()について「This is necessary to let JavaScript frameworks (such as Angular) read it」と述べ、JavaScriptから読む必要がないならnew CookieCsrfTokenRepository()でHttpOnlyを維持するほうが安全だと推奨しています。判断軸は防御の強弱ではなく、その方式が読み取りを必要とするか。hidden項目に埋め込むSynchronizer Token方式なら、そもそもCookieでトークンを配る必要がありません。

SPA・API・JWTでのCSRFトークン設計

フロントエンドとAPIが分離した構成では、まず対策が必要かどうかの判断が変わります。ここを認証方式で判断すると誤ります。

JWTをCookieに置くかAuthorizationヘッダーに置くかの分岐

「JWT認証だからCSRF対策は不要」という整理は、保存場所を省略しているため成り立ちません。CSRFが成立する条件は、ブラウザが認証情報を自動で付けてしまうことです。

JWTをAuthorizationヘッダーにBearerとして載せる構成なら、そのヘッダーはアプリケーションのJavaScriptが明示的に付けるもので、攻撃者のページから発火したリクエストには付きません。この場合CSRFは成立せず、トークン検証も不要。一方JWTをCookieに保存した瞬間、ブラウザはクロスサイトのリクエストにも自動添付します。セッションIDをCookieに置くのと同じ状況になり、対策が要ります。JWTがステートレスであることは、この判断に影響しません。

カスタムヘッダーが検証として機能する条件

OWASPは「Since requests with custom headers are automatically subject to the same-origin policy, it is more secure to insert the CSRF token in a custom HTTP request header via JavaScript than adding a CSRF token in the hidden field form parameter」として、hidden項目よりカスタムヘッダーのほうが安全だとしています。カスタムヘッダーを付けたリクエストは自動的に同一オリジンポリシーの対象になり、クロスオリジンから送るにはプリフライトの許可が要るためです。

ただしCORS設定が緩ければ崩れます。OWASPはAccess-Control-Allow-Credentialsを有効にしてクロスオリジンでCookieを使う場合の条件として、確実に管理下にある「a few select origins」だけを許可するよう求め、正規表現で全サブドメインを許可する構成を危険だと名指ししています。サブドメインを乗っ取られると、攻撃者が同一オリジンポリシーを迂回してカスタムヘッダー付きのリクエストを偽造できるからです。

Spring Security 7.0(2025年11月17日リリース)以降には、SPA向けのcsrf.spa()が追加されました。Javadocのとおり、HttpOnlyを外したCookieベースのトークンリポジトリと、符号化されたトークンではなく素の値を解決するリクエストハンドラの2点を設定するもので、Cookieの再発行タイミングまでは担当しません。6.5系以前にこのメソッドは存在せず、CookieCsrfTokenRepository.withHttpOnlyFalse()とハンドラを個別に指定します。なおBREACH対策の符号化は6.0以降の仕様で、Cookieに置いた場合JavaScript側には素の値しか渡りません。だからこそサーバー側に素の値を解決するハンドラが要る、という向きです。

CORSがCSRF対策にならない理由|単純リクエストとの境界

CORSをCSRF対策として説明する記事は少なくありませんが、これは誤りです。OWASPのCSRF Prevention Cheat SheetでもCORSは単独の対策としては立てられておらず、カスタムヘッダー方式が依存する前提機構として、また締めるべき設定として登場するだけです。

CORSが制御しているのは、クロスオリジンのレスポンスをスクリプトが読み取ってよいかであって、送信そのものではありません。しかも攻撃者のページに置かれたformからのPOSTは、そもそもCORSの適用対象外のナビゲーションとして送信されます。プリフライトも発生せず、Cookie付きでそのまま届く。攻撃者は同一オリジンポリシーによってレスポンスの中身を読めませんが、CSRFは操作が実行されること自体が目的なので、止まるのは結果を知る経路だけです。

プリフライトが起きるのは、Content-Typeapplication/jsonを指定した場合やカスタムヘッダーを付けた場合です。これが結果的に防御として働くのは事実ですが、効いているのはCORSではなく「フォームでは送れない形式のリクエストをサーバーが要求している」という設計のほう。ここに頼るなら、JSON以外のContent-Typeを受け付けないことを保証してください。むしろCORS設定は緩めると危険が増える側で、Access-Control-Allow-Credentialsを有効にしたままOriginを反射的に許可すると、攻撃者のページが認証済みレスポンスの中身を読めるようになります。CORS自体の仕組みとプリフライトの条件はCORSとは?仕組み・プリフライト・サーバー設定例・エラー解決を実装目線で解説で扱っています。

OAuth 2.0のstateパラメータによるCSRF防止

OAuth 2.0のリダイレクトURIも、CSRFの標的になります。RFC 6749の10.12節によれば、攻撃者は自分の認可コードまたはアクセストークンを注入でき、その結果クライアントは、被害者ではなく攻撃者の保護リソースに紐付いたアクセストークンを使ってしまいます。RFCが挙げる例は、被害者の銀行口座情報が攻撃者の管理する保護リソースへ保存されてしまうというもの。

同節は「The client MUST implement CSRF protection for its redirection URI」と、クライアント側での対策を必須と定めています。手段については「The client SHOULD utilize the "state" request parameter to deliver this value to the authorization server」とあり、stateの利用自体はSHOULD。必須なのは対策そのもので、stateはその標準的な運び方という関係です。

値の要件も定めがあり、結び付け値は推測不能であること、利用者エージェントの認証済み状態はクライアントと利用者エージェントだけがアクセスできる場所に保持することを求めています。推測不能の程度は10.10節が定量化しており、攻撃者が推測できる確率は2の-128乗以下でなければならず(MUST)、2の-160乗以下であることが望ましい(SHOULD)とされています。実装は、この強度のランダム値を生成してセッションに保存し、コールバック時に照合したうえで破棄する流れ。認可フロー全体はOAuth 2.0とは?仕組み・認可フローと認証・認可の違いをわかりやすく解説で扱っています。

SameSite CookieとSec-Fetch-Siteによる多層防御

トークン検証の外側に、ブラウザ側の情報を使った層を重ねられます。どちらもトークン検証の置き換えにはなりません。

SameSite属性は、Cookieをクロスサイトのリクエストに付けるかどうかを制御します。MDNの定義では、Strictは同一サイト由来のリクエストにのみ送信、Laxは同一サイト由来に加えてトップレベルナビゲーションかつ安全なメソッドの場合に送信、Noneはクロスサイトにも送信しSecure属性が必須です。属性未指定時にLaxを既定とするブラウザでは、その既定Laxがより緩い版で、設定から2分以内であればトップレベルのクロスサイトPOSTにもCookieを付けます(2026年8月時点。これはChromiumが一時的な互換性介入と位置づけているもので、将来撤去されうる挙動です)。

OWASPがSameSite単独で足りるとするのは、次の5つをすべて満たす場合に限られます。

  • 制御外のホストと登録可能ドメインを共有していない
  • GETなど安全なメソッドで状態を変更するエンドポイントが1つも無い
  • セッションCookieがSameSite=Strict、またはSameSite=Lax__Host-接頭辞と全GETハンドラの厳密な監査を組み合わせている
  • 状態変更エンドポイントにOriginまたはReferer検証がある
  • SameSiteを強制しないブラウザの利用者を除外できる、または残存リスクを受容できる

満たさないなら「SameSite should be treated as a defense-in-depth layer and combined with a CSRF token or a double-submit pattern rather than relied on alone」が結論です。

より新しい層がSec-Fetch-Siteヘッダーで、OWASPもFetch Metadataヘッダーとして独立した節を割いています。MDNによれば値はcross-sitesame-originsame-sitenoneの4種。Sec-接頭辞を持つ禁止リクエストヘッダーなので、スクリプトからは偽装できません。web-featuresのデータでは、Baseline「新規に利用可能」が2023年3月27日(Chrome 76、Edge 79、Firefox 90、Safari 16.4)、その30か月後の2025年9月27日に「広く利用可能」へ到達しています。

const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);

function isAllowed(req, { allowSameSite = false } = {}) {
  if (SAFE_METHODS.has(req.method)) return true;

  const site = req.headers['sec-fetch-site'];
  if (site === undefined) return null;      // 古いブラウザ。トークン検証へ回す
  if (site === 'same-origin') return true;
  if (site === 'same-site') return allowSameSite;
  return false;                              // cross-site と none は拒否
}

ヘッダーが存在しない場合にnullを返し、拒否ではなくトークン検証へ回している点が実装上の要点です。ここで拒否に倒すと、ヘッダーを送らない環境の正規利用者を締め出すことになります。

この構成を標準採用したのがLaravel 13です。従来のCSRFミドルウェアVerifyCsrfTokenValidateCsrfTokenは別名)はPreventRequestForgeryへ改称され、旧名は非推奨エイリアスとして残っています。処理は二層構成で、まずSec-Fetch-Siteを確認し、同一オリジン由来ならトークン検証なしで通す。通らなかった場合に従来のトークン検証へフォールバックする順序です。preventRequestForgery(originOnly: true)でフォールバックを外すとオリジン検証だけに依存でき、その失敗応答はトークン不一致の419ではなく403になります。サブドメイン間を許可するならallowSameSite: trueです。

注意点がひとつ。Sec-Fetch-Siteは、送信先が「潜在的に信頼できるURL」の場合にのみ送出される仕様です。実務上はほぼHTTPSに限られますが、http://localhosthttp://127.0.0.1もこれに含まれるため、ローカル開発サーバーでは平文HTTPでも第一層が働きます。本番相当のホスト名をHTTPで提供している場合はヘッダーが送られず、常にトークン検証へ落ちます。

よくある質問

CSRFトークンとは何ですか?

サーバーが利用者ごとに発行する推測不能な値で、リクエストが本当にそのサイトの画面から発行されたものかを検証するために使います。攻撃者のページからは同一オリジンポリシーによってこの値を読み取れないため、値を含まないリクエストを拒否するだけで、なりすましリクエストを機械的に排除できます。フォームのhidden項目に埋め込む方式と、Cookieとリクエストヘッダーの両方に載せる方式があります。

CSRFトークンが一致しないと表示されたときはどうすればよいですか?

多くは攻撃ではなく、セッション切れ、複数タブでのログインし直し、キャッシュされた古いフォームの再送信が原因です。フレームワークごとに応答も異なり、Laravelは419、Djangoは403を返します。原因の切り分け手順はInvalid CSRF tokenの意味と直し方|Laravel 419・Django・Spring Securityのエラーを切り分けるで扱っています。検証を無効化して回避するのは、根本原因を残したまま防御を外すことになるため避けてください。

CSRFトークンはリクエストごとに再発行すべきですか?

セッション単位で十分です。OWASPはリクエストごとの更新のように頻繁すぎる再発行を「a misconception」と評し、実際にはセキュリティを大きく高めないままユーザー体験を損なうと指摘しています。同じ理由で、トークンに有効期限のタイムスタンプを埋め込む設計も推奨されていません。CSRFトークンはアクセストークンではなく、セッション情報を使ってリクエストの真正性を確かめる値だからです。新しいセッションでは新しいトークンを発行してください。

マイクロサービス間の内部APIにもCSRF対策は必要ですか?

ブラウザを経由しない通信であれば不要です。CSRFはブラウザがCookieを自動送信することで成立する攻撃なので、サーバー間でAPIキーやmTLSを使って呼び合う経路には成立要件がありません。判断すべきは、そのエンドポイントがブラウザのCookie認証で到達可能かどうかです。ブラウザからも叩けるなら、内部向けという位置づけに関係なく対策が要ります。

ファイルアップロードのフォームでもCSRFは成立しますか?

成立します。multipart/form-dataは単純リクエストに含まれるContent-Typeで、プリフライトを起こさずクロスサイトから送信できるためです。ファイルを扱う画面だから安全ということはなく、通常のPOSTと同じくトークン検証を通してください。Content-Typeの制限をCSRF対策の代わりにする場合も、multipart/form-dataapplication/x-www-form-urlencodedtext/plainの3つは素通しになる点に注意が必要です。

関連記事

資料請求

RELATED POSTS 関連記事