メインコンテンツまでスキップ

Cookie と CSRF のアンチパターン集

BFF / SPA / IdP 実装で繰り返し踏まれるパターンをまとめたもの。 各項目は「症状 → なぜダメか → 正しくは」の形式。

前提:なぜ CSRF が起きるのか

Cookie は ambient authority(環境権限)であり、保持者が明示的に提示しなくても自動で効く。 これは仕様上の性質であってバグではない。CSRF 対策とは、この性質を後付けで打ち消す作業である。

元々の設計:発信元を一切見なかった

Cookie の送信可否は、本来「送信先が Domain / Path 条件に合致するか」だけで決まっていた。 リクエストがどのページから発生したかは判定に入らない。

発信元: https://evil.com           ← 判定に使われない(SameSite 以前)
送信先: https://bff.example.com/api/transfer
→ Domain / Path が合致するので Cookie を添付

サーバ側から見ると、正規の画面から来たリクエストと完全に見分けがつかない。 Cookie は正しく、セッションも有効だからである。

現在:発信元も見るようになった(ただし別の単位で)

SameSite の既定化(2020年前後)以降、ブラウザは発信元も考慮する。

  • SameSite 判定のために発信元の site を見る
  • Origin ヘッダを付与する
  • Sec-Fetch-Site を付与する

判定単位が3つに分かれているのが混乱の元

同じ「クロスサイトかどうか」に見えて、仕組みごとに判定単位が異なる

仕組み判定単位a.example.comb.example.com
SOP / CORS / Origin ヘッダオリジンscheme://host:port別物
SameSitesite(schemeful eTLD+1)同じ
Cookie のスコープDomain 属性 + Path(ポートは無視)Domain 指定次第

Cookie の送信先マッチングはオリジン単位ではなく、Cookie 独自の Domain / Path ルールで決まる。 ポートを区別しない点に注意(F-1 参照)。

B-1 のサブドメイン穴はこの単位のズレが原因である。 CORS はオリジンで判定するので evil.example.com を別物として扱えるが、 SameSite は site で判定するため同一サイト扱いになり素通しする。

自動送信される認証情報はすべて対象

認証方式送信のトリガーCSRF
Cookieブラウザが自動起きる
HTTP Basic 認証ブラウザが自動起きる
クライアント証明書 (mTLS)ブラウザが自動起きる
Authorization ヘッダJS が明示的に付ける起きない

A. 認識レベルのアンチパターン

A-1. 「SPA だから CSRF は関係ない」

症状 Bearer トークン時代の感覚のまま Cookie ベースへ移行し、CSRF 対策を入れ忘れる。

なぜダメか Authorization ヘッダ方式で CSRF が起きなかったのは、トークンが同一オリジンの JS からしか読めず、 攻撃者のオリジンから付けようがなかったため(構造的な防御)。 Cookie に移した瞬間にこの構造は消える。

正しくは 保存先を Cookie に変更する判断と、CSRF 対策を入れる判断はセットで行う。片方だけの移行はしない。


A-2. 「HttpOnly にしたから安全」

症状 XSS 対策として HttpOnly Cookie を導入し、それで対策完了とみなす。

なぜダメか HttpOnly が防ぐのは「JS からの読み取り」だけ。CSRF は読み取りを必要としない攻撃なので一切効かない。 XSS と CSRF は別軸の脅威であり、どちらか片方を選ぶ構造になっている。

        Cookie      Bearer(Web Storage)
XSS 強い 弱い
CSRF 弱い 強い

正しくは 二本立てで対策する。

  • XSS 側 → HttpOnly Cookie + CSP + 依存関係の監査
  • CSRF 側 → SameSite + Origin 検証(+必要なら CSRF トークン)

A-3. 「CORS があるから守られている」

症状 CORS を設定してあるので、クロスオリジンからのリクエストは弾かれると考える。

なぜダメか CORS は制限を緩める仕組みであって、守る仕組みではない。 また同一オリジンポリシー(SOP)が制限するのはレスポンスの読み取りであり、リクエストの送信ではない。

evil.com → bff.example.com へ POST
送信: 通る(Cookie 付き)
読み取り: SOP でブロック

とくに <form> 送信は CORS の管轄外。 application/x-www-form-urlencoded / multipart/form-data / text/plain の POST は 「単純リクエスト」扱いでプリフライトが飛ばない。

なお CORS の判定単位はオリジンであり、SameSite の site とは異なる(前提セクション参照)。 「CORS で弾けるから SameSite でも弾ける」という推論は成り立たない。

正しくは CORS を CSRF 対策として数えない。サーバ側で Origin を検証する。


A-4. 「攻撃者にレスポンスは読めないから実害はない」

症状 CSRF はレスポンスが読めないので情報漏洩にはならない、と軽視する。

なぜダメか CSRF は「撃てるが結果は見えない」攻撃。危険なのは状態変更であって読み取りではない。

  • メールアドレス変更 + パスワードリセット → アカウント乗っ取りに直結
  • 2FA 無効化
  • 送金、購入、退会
  • 権限付与、API キー発行

正しくは 状態変更を伴うすべてのエンドポイントを保護対象とみなす。


B. SameSite まわりのアンチパターン

B-1. SameSite=Lax を万能だと思う

症状 SameSite=Lax を設定したので CSRF 対策は完了、と判断する。

なぜダメか Lax には明確な穴が4つある。

ケースLax で防げるか
クロスサイト POST
クロスサイト GET で状態変更
サブドメイン間evil.example.comapi.example.com
古いブラウザ / SameSite 未対応 UA

とくにサブドメインの穴が重要。SameSite は site 単位(schemeful eTLD+1) であってオリジン単位ではない。 CORS がオリジン単位で判定するのと単位がズレているため、 CORS では別物として扱える evil.example.com が、SameSite では同一サイト扱いになる。 ユーザー投稿を置いている CDN サブドメインに XSS が1つあれば、SameSite は貫通する。

正しくは Lax は基礎防御と位置づけ、Origin 検証を必ず併用する。


B-2. GET に副作用を持たせる

症状

GET /api/items/123/delete
GET /api/logout
GET /api/settings?theme=dark (保存される)

なぜダメか SameSite=Lax はトップレベル GET 遷移では Cookie を送る。つまり GET は素通しになる。 さらに JS すら不要で成立する。

<img src="https://bff.example.com/api/items/123/delete">

画像として壊れていても、リクエストは既に届いている<link> <iframe> <video> <script>、CSS の background-image —— 外部リソースを読み込む仕組みはすべて攻撃経路になる。

正しくは GET は安全(safe)かつ冪等に保つ。状態変更は POST / PUT / PATCH / DELETE に寄せる。 これは設計レベルの前提であり、ここが崩れていると他の対策が効かなくなる。


B-3. form_post 対応で SameSite=None にして、そのまま放置

症状 OIDC の response_mode=form_post を導入したところ「state が見つからない」エラーが多発。 調べて SameSite=None; Secure に変更したら直ったので、そのまま本番へ。

なぜダメか form_post は認可サーバからのクロスサイト POST なので Lax では Cookie が飛ばない。 これ自体は正しい対処だが、None にした時点で CSRF 防御が完全に消える

response_mode戻り方必要な SameSite
query / fragmentトップレベル GET 遷移Lax でOK
form_postクロスサイト POSTNone; Secure 必須

正しくは 以下のいずれかを取る。

  1. None にする Cookie をコールバック処理専用に限定する(Path を絞り、TTL を数分にする)。 アプリ本体のセッション Cookie は Lax のまま分離する。
  2. response_mode=query で足りるなら、そもそも form_post を使わない。
  3. None を使うなら Origin 検証と CSRF トークンを必ず併用する。

B-4. Domain 属性を無警戒に広げる

症状

Set-Cookie: session=...; Domain=example.com

「サブドメインでも使いたいから」で安易に指定する。

なぜダメか Cookie が全サブドメインに送られるようになる。 サブドメインが1つでも侵害されれば(XSS、サブドメインテイクオーバー、放置された古いサービス)、 セッション Cookie がそこへ流れる。B-1 のサブドメイン穴と組み合わさると被害が拡大する。

正しくは Domain を指定せずホスト限定にする。必要な場合のみ最小範囲で指定する。 併せて __Host- プレフィックスを使う。

Set-Cookie: __Host-session=...; Secure; Path=/; SameSite=Lax

__Host- を付けると Domain 指定が禁止され、Path が / 強制、Secure 必須になる。 サブドメインからの Cookie 上書き(Cookie tossing)を防げる。


C. トークン保管のアンチパターン

C-1. アクセストークン / リフレッシュトークンを Web Storage に置く

症状 localStorage にトークンを保存。指摘されて sessionStorage に変更し、対策完了とする。

なぜダメか XSS に対してはほぼ変わらない。どちらも同じ JS コンテキストから同期的に読める。

sessionStorage.getItem('token')  // 同じ1行で読める

sessionStorage が優れているのは XSS 以外の部分(タブを閉じたら消える、タブ単位で隔離、 永続しない)であり、窃取の可否は変わらない。差は攻撃の持続時間だけ

とくにリフレッシュトークンは長期の再発行権そのものなので、Web Storage に置いてはいけない。

正しくは

対策XSS 耐性
localStorage
sessionStorage✗(持続時間が短いだけ)
JS メモリ変数△(窃取窓が狭いだけ)
HttpOnly Cookie
BFF(トークンをブラウザに出さない)
DPoP / mTLS(送信者制約)◎(盗まれても他所で使えない)

優先順位は以下。保存先の議論は最後でよい。

  1. XSS そのものを潰す(CSP、フレームワークのエスケープ、依存関係の監査)
  2. リフレッシュトークンをブラウザから出す
  3. アクセストークンを短命にする
  4. 保存先を選ぶ

症状 BFF をステートレスにしたいので、アクセストークン / リフレッシュトークンを そのまま HttpOnly Cookie の値として返す。

なぜダメか sessionStorage より確実にマシだが、BFF セッション方式より明確に劣る。

sessionStorageCookie に直接格納BFF セッション
XSS で窃取
Cookie 窃取マルウェア○(ID のみ)
即時失効
サイズ制限問題なし4KB の壁問題なし
CSRF 対策不要必要必要

具体的な問題:

  • 失効できない。「このセッションを今すぐ切る」ができず、exp まで有効のまま
  • 4KB 制限。RFC 9068 準拠の JWT に claim を盛ると超える。分割し始めると壊れる
  • ローテーションが辛い。同時リクエストで競合し、古い refresh token の使用が 再利用検知に引っかかって全セッション破棄、という事故が起きる
  • 端末侵害(infostealer)に耐性がない。ディスク上の Cookie ストアを直接抜かれる

正しくは BFF がセッションストアにトークンを保持し、ブラウザにはセッション ID だけを返す。 Redis を1つ立てられるなら、こちらにする。


C-3. code_verifier をブラウザに出す

症状 SPA で PKCE を実装し、code_verifiersessionStorage に保存する。

なぜダメか XSS で読める。また外部ブラウザ / Custom Tabs 経由で戻ると sessionStorage が読めず、 認可フローそのものが壊れる。

正しくは BFF 構成なら、state をキーにしたサーバ側セッションに格納する。

session[state] = { verifier, nonce, return_to, created_at }

ブラウザには紐付けキー(= state と Cookie)だけを渡す。


C-4. セッションを切ればトークンも止まると思う

症状 ログアウトでサーバ側セッションを破棄し、それで全部無効になったとみなす。

なぜダメか セッションとアクセストークンは別々に期限を持つ。BFF ではこの2つが同居する。

   ログアウト

┌──────────────────┐
│ セッション │ ← 即座に無効化できる
└──────────────────┘
┌──────────────────────────────────┐
│ アクセストークン │ ← 有効期限まで生きる
└──────────────────────────────────┘
↑ ここが残る

リソースサーバが JWT を自前検証している場合、認可サーバに問い合わせないため トークンが無効化されたことに気づけない

正しくは 以下のいずれか、または組み合わせ。

方法効果コスト
アクセストークンを短命にするズレの幅を小さくする更新回数が増える
ログアウト時に失効させる(RFC 7009)ズレを無くすリソースサーバが introspection を見る必要がある
識別子型トークン + introspection常に最新の状態を見る呼び出しごとに問い合わせ

JWT の自己完結性と即時失効は両立しない。 どちらを取るかの判断になる。


D. 検証実装のアンチパターン

D-1. state を「値の一致」だけで検証する

症状 リクエストに載っていた state を、そのままレスポンスの state と比較して終わり。

なぜダメか state 検証の本質は「このブラウザが開始した認可か」の確認であって、値の一致ではない。 値の実体をクライアント側だけに持たせると、攻撃者が自分で用意した state を送り込める。

正しくは state の実体はサーバ側に置き、以下を実施する。

  1. state が存在する(無ければ即エラー。error パラメータ時も検証してから返す)
  2. 定数時間比較で照合
  3. 照合成功したら即削除(単回使用)
  4. TTL 超過(5〜10分程度)で破棄
  5. 紐づく code_verifier を取り出して token リクエストへ
  6. nonce は ID Token 側で別途検証

state / PKCE / nonce は役割が異なるので、兼用させない。

  • state:CSRF 防止 + アプリ状態の復元
  • PKCE:認可コード横取り防止
  • nonce:ID Token のリプレイ防止

D-2. Origin / Referer を検証していない

症状 SameSite だけに依存し、サーバ側で発信元を確認していない。

なぜダメか B-1 のサブドメイン穴、B-3 の None 設定、古い UA —— SameSite が効かないケースが現実に存在する。

正しくは Origin ヘッダを allowlist と照合する。実装コストが低く効果が高い。

  • Originブラウザが強制的に付けるので JS から偽装できない
  • POST には必ず付く
  • 無い場合は Referer にフォールバック。どちらも無ければ拒否

D-3. CSRF トークンをセッションに紐づけていない

症状 CSRF トークンを発行しているが、セッションと無関係な値、または全ユーザー共通の固定値。

なぜダメか 攻撃者が自分のアカウントで正規のトークンを取得し、それを攻撃ページに埋め込めば通ってしまう。

正しくは synchronizer token pattern(セッションに紐づけてサーバ側で照合)か、 double submit cookie を使う。後者を使う場合は __Host- プレフィックスを併用し、 サブドメインからの Cookie 上書きを防ぐ。

CSRF トークンは XSS の保険にならない

CSRF トークンは「攻撃者はページの中身を読めない」という前提に立っている。 XSS が成立した時点でその前提が崩れるため、攻撃者は正規のフォームからトークンを読み、 正規の手順でリクエストを組み立てられる。

XSS 対策の代替として CSRF トークンを数えないこと。


D-4. 単一キーで state を保存し、複数タブで壊れる

症状

session["oauth_state"] = state   // 後勝ちで上書きされる

2つのタブから認可を開始すると、先に開始したタブが壊れる。

正しくは セッション内に state をキーとした辞書を持つ。

session.oauth[state] = { verifier, nonce, return_to }

上限を 3〜5 件程度に設定し、古いものから破棄する。


E. リダイレクト復帰まわりのアンチパターン

E-1. コールバック後に URL を掃除しない

症状 ?code=...&state=... が付いたまま画面を表示し、そのまま放置する。

なぜダメか

  • リロードで code が再送され invalid_grant になる
  • ブラウザ履歴に code が残る
  • 同一オリジン遷移では Referrer-Policy: strict-origin-when-cross-origin でも フル URL が送られるため、サイト内リンクから code が漏れる
  • Analytics のページビュー送信に URL ごと乗る

正しくは token 交換の直後に history.replaceState で query を除去してから遷移する。 併せてコールバックページに Referrer-Policy: no-referrer を明示する。


E-2. sessionStorage だけで復帰状態を持つ

症状 認可リダイレクトからの復帰情報を sessionStorage のみに保存する。

なぜダメか sessionStorageタブ単位。以下のケースで読めなくなる。

  • iOS の SFSafariViewController / Android の Custom Tabs 経由
  • 認可画面が別タブで開いた場合
  • ユーザーが URL を手動で別タブに貼り直した場合

「Cookie の session(ブラウザを閉じるまで・全タブ共有)」と 「sessionStorage の session(そのタブが生きている間だけ)」は粒度が異なる。

正しくは サーバセッション(Cookie)を正とし、sessionStorage は UI 復元の補助に留める二段構え。


E-3. UI 状態の永続化に機密情報を巻き込む

症状 Zustand persist や TanStack Query persister をデフォルト設定のまま導入し、 ストア全体を localStorage に保存する。

なぜダメか API レスポンスや個人情報がそのまま平文で残る。トークンがストアに含まれていれば一緒に漏れる。

正しくは 保存対象を明示的に絞る。

persist(store, {
name: 'draft',
storage: createJSONStorage(() => sessionStorage),
partialize: (s) => ({ formDraft: s.formDraft }), // トークンは含めない
})

TanStack Query なら shouldDehydrateQuery で個人情報を含むクエリを除外する。 決済情報・個人情報は退避対象にしない。


F. 運用・開発環境のアンチパターン

F-1. ポートでセッションが分離されると思い込む

症状 localhost:3000localhost:8080 で別プロジェクトを開発し、セッションが混ざる。

なぜダメか Cookie はポートを区別しない。ホスト名が同じなら共有される。

正しくは 開発時は app.localhost / api.localhost のようにホスト名を分けるか、 ブラウザプロファイルを分ける。


F-2. タブを分ければ別アカウントで検証できると思う

症状 マルチテナントの検証で、タブを分けてテナント A / B に同時ログインしようとする。

なぜダメか Cookie はブラウザプロファイル単位で共有される。タブ単位ではない。

単位CookiesessionStoragelocalStorage
タブ共有隔離共有
ウィンドウ共有隔離共有
プロファイル共有(この単位)共有
別プロファイル隔離隔離隔離
シークレット隔離隔離隔離

正しくは プロファイルを分けるか、シークレットウィンドウ / ゲストプロファイルを使う。


チェックリスト

Cookie ベース認証を導入する際の最低ライン。

  • SameSite=Lax(可能なら Strict)を設定した
  • Secure を設定した
  • HttpOnly を設定した
  • __Host- プレフィックスを付けた(Domain を広げていない)
  • サーバ側で Origin を allowlist 照合している
  • GET に副作用のあるエンドポイントが存在しない
  • SameSite=None にした Cookie がある場合、用途と Path を限定し CSRF トークンを併用している
  • リフレッシュトークンがブラウザの Web Storage に存在しない
  • state が単回使用・TTL 付き・定数時間比較で検証されている
  • state が複数タブに対応している(単一キー上書きになっていない)
  • コールバック後に URL から code / state を除去している
  • 状態変更エンドポイントが網羅的に保護されている(追加時の漏れを防ぐ仕組みがある)
  • CSP を設定している(XSS 側の対策が別途ある)
  • ログアウト時のアクセストークンの扱いを決めてある(C-4)

関連ドキュメント

ドキュメント内容
Webセッションの基礎Cookie の仕組み、オリジン、サーバーサイドセッション
セッションセキュリティセッション攻撃、Cookie 属性、ライフサイクル
トークン保存のセキュリティ保存場所ごとの性質
OAuth セキュリティ脅威CSRF を含む攻撃手法の全体像
RFC 10017ブラウザアプリのアーキテクチャ選択

参考


最終更新: 2026-08-28 対象: フロントエンド開発者、バックエンド開発者、セキュリティエンジニア