Double Submit Cookie完全実装|Synchronizer Token比較・SPA+JWT・サブドメイン攻撃

CSRF セキュリティ Cookie Express 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)」させる方式です。

1

サーバーが暗号学的乱数(例: 32バイト)を生成し、CSRF用Cookieとしてブラウザに保存する

2

フロントエンドがそのCookieをJavaScriptで読み取り、状態変更リクエストのヘッダー(例: X-XSRF-TOKEN)に同じ値を載せる

3

サーバーはセッションを参照せず、Cookieの値とヘッダー(またはボディ)の値が一致するかだけを検証する

4

攻撃者サイトはクロスオリジンから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を返せます。

HttpOnlyは付けられない

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 を付与するのが定番です。

Origin / Referer検証を足す

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に置くのは推奨されません。

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 CookieDouble Submit + SameSite + Origin検証
セッションCookie(HttpOnly)Synchronizer Token または Double Submit

サブドメイン攻撃と__Host- Cookieの限界

Double Submit Cookieの前提は、攻撃者がCSRF用Cookieの値を書き換えられないことです。しかし、同一 registrable domain(例: example.com)内に攻撃者が制御できるサブドメインがあると、この前提が崩れます。

攻撃シナリオ

  1. 会社が app.example.com(本番)と staging.example.com(検証環境)を運用している
  2. 検証環境にXSSや過剰なCookie設定権限があり、攻撃者が Domain=.example.com 付きで XSRF-TOKEN=attacker-controlled を書き込める
  3. 被害者が 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に副作用を載せないログイン・権限変更時のトークンローテーションも有効です。

テストと運用上の落とし穴

1

トークン欠落のPOST → 403になることを確認する

2

Cookieとヘッダーの値を意図的に不一致にする → 403

3

クロスオリジンのform POST(SameSite=Lax環境)→ Cookieが付かないか403

4

ログアウト後に古いCSRF Cookieが残っていないか確認する

5

サブドメイン環境でCookie上書きテストを行う(ステージングと本番の分離確認)

6

E2E(Playwright等)でfetchインターセプタが全状態変更APIにヘッダーを付けているか検証する

よくある実装バグは次のとおりです。

  • Cookie名とヘッダー名の不一致XSRF-TOKEN vs X-XSRF-TOKEN vs X-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ではissueCsrfCookieverifyCsrfDoubleSubmitの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チェックを多層で重ね、認証アーキテクチャに合った方式を選んでください。