Double Submit Cookie完全実装|Synchronizer Token比較・SPA+JWT・サブドメイン攻撃
- Double Submit Cookieは、CSRFトークンをCookieとリクエストヘッダーの両方に載せ、サーバーが「一致するか」だけを検証するステートレス方式です。
- Expressでは自前ミドルウェア数十行で実装できますが、HttpOnlyにできない・信頼できないサブドメインがあると破綻という制約があります。
- CSRF対策の実装ガイドで解説しているSameSiteやreturn path検証と併用し、Synchronizer Tokenとのトレードオフを理解したうえで選んでください。
CSRFの再確認:Double Submit Cookieを検討する理由
CSRF(Cross-Site Request Forgery)は、ログイン済みユーザーのブラウザが、意図しない状態変更リクエストを送らされる攻撃です。攻撃者サイトのHTMLフォームやJavaScriptから、被害者の認証Cookieが自動付与されたPOST・PUT・DELETEが飛びます。攻撃者はSame-Origin PolicyによりCookieの中身を読めませんが、ブラウザがCookieを付けて送ること自体は止められません。
防御の核心は「攻撃者サイトから再現できない秘密情報」をリクエストに含めさせることです。代表的手法が次の2つです。
- Synchronizer Token — サーバー(セッション)にトークンを保存し、フォームやAPIヘッダーと照合する
- Double Submit Cookie — Cookieとリクエスト本体の両方に同じ乱数を載せ、一致すれば正当とみなす(セッション不要)
Double Submit Cookieが選ばれる典型理由は、セッションストアを増やしたくない、マイクロサービスやサーバーレスAPIで水平スケールしたい、JWT認証と併用するAPIゲートウェイなどです。一方、伝統的なMVCアプリでセッション管理が既にあるなら、Synchronizer Tokenの方がHttpOnly Cookieと相性が良い場面も多いです。
Double Submit Cookieの原理
Double Submit Cookie(Double Submit Cookie Pattern)は、OWASPが整理しているCSRF防御パターンのひとつです。名前のとおり、同じトークンを2か所に「提出(submit)」させる方式です。
サーバーが暗号学的乱数(例: 32バイト)を生成し、CSRF用Cookieとしてブラウザに保存する
フロントエンドがそのCookieをJavaScriptで読み取り、状態変更リクエストのヘッダー(例: X-XSRF-TOKEN)に同じ値を載せる
サーバーはセッションを参照せず、Cookieの値とヘッダー(またはボディ)の値が一致するかだけを検証する
攻撃者サイトはクロスオリジンからCookieを読めないため、正しいヘッダー値を付けられない
Set-Cookie: XSRF-TOKEN=a1b2c3d4e5f67890; Path=/; Secure; SameSite=Lax
// フロント:Cookie → ヘッダーへコピー
await fetch('/api/profile', {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'X-XSRF-TOKEN': getCookie('XSRF-TOKEN'),
},
credentials: 'include',
body: JSON.stringify({ name: 'Kawa' }),
});
攻撃者が <form method="POST" action="https://app.example/api/transfer"> を仕込んでも、カスタムヘッダーは素朴なHTMLフォームから送れません(CORSプリフライトが絡む)。Cookieだけが自動送信され、ヘッダー側のトークンが欠落または不一致になるため、サーバーは403を返せます。
Double SubmitではJavaScriptがCookieを読む必要があるため、CSRF用CookieにHttpOnlyは付けられません。XSSが存在する環境では、攻撃者スクリプトがCookieを読み取り、正当なヘッダー付きリクエストを再現できます。CSRF対策とXSS防御はセットで考えてください。
ExpressでのDouble Submit Cookie完全実装
以下は、Express 4/5向けの自前ミドルウェアによる完全実装例です。csurfは非推奨のため、新規コードではこのパターンを参考にしてください。
依存関係と定数
npm install express cookie-parser
// middleware/csrfDoubleSubmit.js
import crypto from 'node:crypto';
export const CSRF_COOKIE_NAME = 'XSRF-TOKEN';
export const CSRF_HEADER_NAME = 'x-xsrf-token'; // Expressはヘッダーを小文字化
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
/** 定数時間比較(タイミング攻撃対策) */
function safeEqual(a, b) {
if (typeof a !== 'string' || typeof b !== 'string') return false;
const bufA = Buffer.from(a);
const bufB = Buffer.from(b);
if (bufA.length !== bufB.length) return false;
return crypto.timingSafeEqual(bufA, bufB);
}
/** 32バイトのURL-safe Base64トークン */
export function generateCsrfToken() {
return crypto.randomBytes(32).toString('base64url');
}
/** GET等の安全メソッドでCookieを発行・更新 */
export function issueCsrfCookie(req, res, next) {
if (!SAFE_METHODS.has(req.method)) return next();
let token = req.cookies?.[CSRF_COOKIE_NAME];
if (!token) {
token = generateCsrfToken();
res.cookie(CSRF_COOKIE_NAME, token, {
path: '/',
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
// HttpOnly: false が暗黙(JSから読むため)
maxAge: 60 * 60 * 8, // 8時間
});
}
next();
}
/** 状態変更リクエストでCookieとヘッダーを照合 */
export function verifyCsrfDoubleSubmit(req, res, next) {
if (SAFE_METHODS.has(req.method)) return next();
const cookieToken = req.cookies?.[CSRF_COOKIE_NAME];
const headerToken =
req.headers[CSRF_HEADER_NAME] ||
req.body?._csrf; // フォームPOST互換
if (!cookieToken || !headerToken || !safeEqual(cookieToken, headerToken)) {
return res.status(403).json({ error: 'Invalid CSRF token' });
}
next();
}
アプリケーションへの組み込み
// app.js
import express from 'express';
import cookieParser from 'cookie-parser';
import {
issueCsrfCookie,
verifyCsrfDoubleSubmit,
} from './middleware/csrfDoubleSubmit.js';
const app = express();
app.use(cookieParser());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// 全リクエスト:安全メソッドでCSRF Cookieを配布
app.use(issueCsrfCookie);
// APIルート:状態変更前に検証
app.use('/api', verifyCsrfDoubleSubmit);
// 認証Cookie(HttpOnly)— CSRF Cookieとは別物
function setSessionCookie(res, sessionId) {
res.cookie('SESSION', sessionId, {
httpOnly: true,
secure: true,
sameSite: 'lax',
path: '/',
});
}
app.post('/api/login', (req, res) => {
// 認証処理…
setSessionCookie(res, 'session-abc123');
res.json({ ok: true });
});
app.patch('/api/profile', (req, res) => {
// verifyCsrfDoubleSubmit を通過済み
res.json({ updated: req.body });
});
app.listen(3000);
フロントエンド(fetchインターセプタ)
// csrfClient.js
function getCookie(name) {
const match = document.cookie.match(
new RegExp('(?:^|; )' + name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '=([^;]*)')
);
return match ? decodeURIComponent(match[1]) : '';
}
export async function apiFetch(url, options = {}) {
const headers = new Headers(options.headers || {});
const token = getCookie('XSRF-TOKEN');
if (token) headers.set('X-XSRF-TOKEN', token);
return fetch(url, {
...options,
headers,
credentials: 'include',
});
}
// 使用例
await apiFetch('/api/profile', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Kawa' }),
});
axiosを使う場合は axios.defaults.withCredentials = true とリクエストインターセプタで X-XSRF-TOKEN を付与するのが定番です。
Double Submitに加え、OriginまたはRefererヘッダーが自サイトであることを確認するミドルウェアを足すと、ヘッダー未送信のレガシークライアントや設定ミスを補えます。CSRF対策の実装ガイドの多層防御と同じ考え方です。
Synchronizer Tokenとの比較
Synchronizer Token(同期トークン)は、サーバー側セッションにCSRFトークンを保存し、HTMLのhiddenフィールド・metaタグ・APIレスポンスでフロントに渡す方式です。Expressでの概念実装は次のとおりです。
// middleware/csrfSynchronizer.js(セッション必須)
import session from 'express-session';
import crypto from 'node:crypto';
export function csrfSynchronizer(req, res, next) {
if (!req.session.csrfToken) {
req.session.csrfToken = crypto.randomBytes(32).toString('base64url');
}
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
return next();
}
const submitted =
req.headers['x-csrf-token'] || req.body?._csrf;
if (
!submitted ||
submitted !== req.session.csrfToken
) {
return res.status(403).json({ error: 'Invalid CSRF token' });
}
next();
}
// SSRテンプレート
// <meta name="csrf-token" content="<%= csrfToken %>" />
| 観点 | Synchronizer Token / Double Submit Cookie |
|---|---|
| トークン保管 | Synchronizer: セッションストア / Double Submit: Cookieのみ(ステートレス) |
| HttpOnly | Synchronizer: セッションCookieはHttpOnly可 / Double Submit: CSRF CookieはJS-readable必須 |
| スケール | Synchronizer: Redis等のセッション共有が必要 / Double Submit: 任意のインスタンスで検証可能 |
| サブドメイン | Synchronizer: トークンはサーバー内のみ / Double Submit: Cookie改ざん可能なサブドメインがあると危険 |
| XSS時 | Synchronizer: meta/DOMからトークン窃取 / Double Submit: Cookie直読みでより窃取しやすい |
| 向く構成 | Synchronizer: SSR・Rails/Laravel系 / Double Submit: SPA+API・JWT併用ゲートウェイ |
選び方の目安は次のとおりです。
- セッション管理が既にあるSSRアプリ → Synchronizer Token(フレームワーク標準機能を使う)
- ステートレスAPI・複数リージョン → Double Submit Cookie + カスタムヘッダー必須
- どちらも SameSite=Lax以上、Secure、状態変更はGET禁止を共通前提にする
SPA + JWT構成:CookieだけではCSRFを防げない理由
SPA(Single Page Application)とJWTの組み合わせでは、認証情報の載せ方によってCSRFリスクが大きく変わります。
パターンA:JWTをAuthorizationヘッダーのみで送る
await fetch('/api/items', {
method: 'POST',
headers: {
Authorization: `Bearer ${localStorage.getItem('access_token')}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ title: 'new' }),
});
この構成では、ブラウザがクロスサイトリクエストに自動付与する秘密がありません。攻撃者サイトのフォームやfetchは、被害者のBearerトークンを知らない限りAPIを叩けません(localStorageはSame-Origin Policyで読めない)。CSRFの典型ターゲットにはなりにくいです。
ただし、XSSがあればlocalStorageからJWTを抜かれるため、XSS対策の方が優先です。また、refresh tokenをlocalStorageに置くのは推奨されません。
パターンB:JWTをHttpOnly Cookieに載せる(BFF / cookie-based SPA)
Set-Cookie: access_token=eyJhbG...; HttpOnly; Secure; SameSite=Lax
HttpOnly CookieにJWTを載せると、JavaScriptからは読めませんが、ブラウザがクロスサイトPOSTにCookieを付ける可能性が戻ります(SameSite=LaxならフォームPOSTは抑止されますが、SameSite=Noneや古いUAでは穴が残る)。この構成ではDouble SubmitまたはSynchronizer Tokenが再び必要です。
// CookieにJWT + Double Submit
await fetch('/api/items', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'X-XSRF-TOKEN': getCookie('XSRF-TOKEN'), // 必須
},
body: JSON.stringify({ title: 'new' }),
});
パターンC:SameSite=Laxだけに頼る
「SameSite=LaxだからCSRFは不要」は危険な短絡です。GETに副作用がある、SameSite=Noneが必要な埋め込み、サブドメイン間の意図しないCookie送信など、Laxだけではカバーしきれないケースがあります。CSRF対策の実装ガイドでも述べているとおり、SameSiteは第一防御、トークン検証は第二防御として設計してください。
| 認証方式 | CSRFリスク | 推奨防御 |
|---|---|---|
| Bearer JWT(メモリ/localStorage) | 低(自動送信なし) | XSS対策・短命トークン |
| JWT in HttpOnly Cookie | 高 | Double Submit + SameSite + Origin検証 |
| セッションCookie(HttpOnly) | 高 | Synchronizer Token または Double Submit |
サブドメイン攻撃と__Host- Cookieの限界
Double Submit Cookieの前提は、攻撃者がCSRF用Cookieの値を書き換えられないことです。しかし、同一 registrable domain(例: example.com)内に攻撃者が制御できるサブドメインがあると、この前提が崩れます。
攻撃シナリオ
- 会社が
app.example.com(本番)とstaging.example.com(検証環境)を運用している - 検証環境にXSSや過剰なCookie設定権限があり、攻撃者が
Domain=.example.com付きでXSRF-TOKEN=attacker-controlledを書き込める - 被害者が
app.example.comにログインした状態で、攻撃者ページがstaging.example.com経由またはサブドメインCookieの上書きで、本番API向けDouble Submitを突破する
Domain属性を付けない(ホストのみCookie)ことでスコープは狭まりますが、サブドメイン側から親ドメイン向けCookieを設定する類の問題は、インフラとアプリの両方で整理が必要です。
__Host-プレフィックス
__Host-プレフィックス付きCookieは、次の制約をブラウザに強制します。
Secure必須Path=/必須Domain属性禁止(サブドメイン共有不可)
Set-Cookie: __Host-XSRF-TOKEN=abc123; Path=/; Secure; SameSite=Lax
__Host-はCookieのスコープを単一ホストに固定するうえで有効です。app.example.com専用のCSRF Cookieが staging.example.com から上書きされにくくなります。
しかし、同一ホスト上のXSSや、別サブドメインが本番と同じCookie名でDomain=.example.comを設定できる運用ミスは、__Host-だけでは防げません。さらにDouble SubmitはCSRF CookieをJavaScript-readableにする必要があり、HttpOnlyの__Host-SESSION(セッション)とは別Cookieとして運用するのが現実的です。
信頼できないサブドメイン(ユーザー生成コンテンツ、古いステージング、サードパーティSaaSのCNAME)を registrable domain 内に置く場合、Double Submit Cookieだけに頼らないでください。サブドメイン分離(別ドメイン化)、Strict SameSite、Synchronizer Tokenへの切り替えを検討してください。
__Secure- との違い
__Secure-はSecure必須のみを強制し、Domainは許容されます。CSRF Cookieには __Host-の方がホスト固定という意味でDouble Submitと相性が良いですが、根本的なサブドメイン統治(不要なサブドメインの削除、HSTS、CSP)が先です。
SameSite・カスタムヘッダー・CORSとの関係
Double Submit Cookieは、カスタムヘッダー(X-XSRF-TOKEN)の送信とセットで設計することが重要です。理由は次のとおりです。
- 素朴な
<form enctype="application/x-www-form-urlencoded">はカスタムヘッダーを付けられない fetchのクロスオリジンリクエストはCORSプリフライトでヘッダー許可が必要であり、攻撃者オリジンからはサーバーが許可しない
// CORS設定例(Express)
import cors from 'cors';
app.use(cors({
origin: 'https://app.example.com',
credentials: true,
allowedHeaders: ['Content-Type', 'X-XSRF-TOKEN'],
}));
SameSite=LaxはクロスサイトPOSTでのCookie送信を抑えますが、CSRF対策の実装ガイドが強調するように、トークン検証と併用してください。加えて、状態変更APIではGETに副作用を載せない、ログイン・権限変更時のトークンローテーションも有効です。
テストと運用上の落とし穴
トークン欠落のPOST → 403になることを確認する
Cookieとヘッダーの値を意図的に不一致にする → 403
クロスオリジンのform POST(SameSite=Lax環境)→ Cookieが付かないか403
ログアウト後に古いCSRF Cookieが残っていないか確認する
サブドメイン環境でCookie上書きテストを行う(ステージングと本番の分離確認)
E2E(Playwright等)でfetchインターセプタが全状態変更APIにヘッダーを付けているか検証する
よくある実装バグは次のとおりです。
- Cookie名とヘッダー名の不一致(
XSRF-TOKENvsX-XSRF-TOKENvsX-CSRF-TOKEN)— プロジェクト内で統一する - URLデコードの二重適用 —
decodeURIComponentをCookie読み取りで二重にかけて不一致になる - APIのみ検証でHTMLフォームを忘れる — レガシーフォームは
body._csrfフォールバックを残す - タイミング攻撃 — 文字列比較は
crypto.timingSafeEqualを使う - HTTPS未対応環境でSecure Cookie — ローカル開発では
secure: falseの切り替えを忘れない
関連記事
この記事は セキュリティ テーマの一環です。あわせて読むと理解が深まる関連記事をまとめました。
| トピック | 記事 |
|---|---|
| Content Security Policy (CSP) 設定ガイド | Content Security Policy (CSP) 設定ガイド|XSS・クリックジャッキング対策 |
| CSP Report-Only 段階導入ガイド | CSP Report-Only 段階導入ガイド|本番を壊さずポリシーを検証する |
| セキュリティヘッダー一覧と設定ガイド | セキュリティヘッダー一覧と設定ガイド|CSP・HSTS・Referrer-Policy・Permissions-Policy |
| セキュアなCookie設定ガイド | セキュアなCookie設定ガイド|HttpOnly・Secure・SameSiteの使い分けとCSRF対策 |
| CORSエラーの対処法 | CORSエラーの対処法|「Access-Control-Allow-Origin」で解決する |
| CORSプリフライトリクエスト | CORSプリフライトリクエスト|OPTIONSが飛ぶ条件と正しい応答 |
| ローカル開発でCORSエラーが出る原因と3つの回避策 | ローカル開発でCORSエラーが出る原因と3つの回避策 |
| OAuth2 PKCE SPA実装ガイド | OAuth2 PKCE SPA実装ガイド|Authorization Code + S256 + BFFパターン |
| Passkeys・WebAuthn実装の基礎 | Passkeys・WebAuthn実装の基礎|FIDO2・Resident Key・@simplewebauthn/server |
| JWTのデバッグ方法 | JWTのデバッグ方法|トークンの中身を安全に確認する |
| JWTの中身を確認する方法 | JWTの中身を確認する方法|改ざんが検知される仕組み |
| JWKSとJWT鍵ローテーション運用ガイド | JWKSとJWT鍵ローテーション運用ガイド|kid検証・ゼロダウンタイム・Node.js実装 |
まとめ
Double Submit Cookieは、Cookieとヘッダーの一致検証だけでCSRFを防ぐステートレスなパターンです。ExpressではissueCsrfCookieとverifyCsrfDoubleSubmitの2ミドルウェアで完結し、SPAではfetchインターセプタでX-XSRF-TOKENを付与します。
一方、Synchronizer TokenはセッションとセットでHttpOnly Cookieを維持でき、サブドメイン改ざんへの耐性が相対的に高いです。SPA+JWTでは、BearerヘッダーのみならCSRFリスクは低いが、JWTをHttpOnly Cookieに載せる瞬間にDouble Submitが再び必要になります。__Host-プレフィックスはホスト固定に有効ですが、信頼できないサブドメイン問題の銀の弾ではありません。
CSRF対策の実装ガイドとあわせ、SameSite・return path検証・Originチェックを多層で重ね、認証アーキテクチャに合った方式を選んでください。