Tech Deep Dive

CSRF Nonceとステートレストークンの比較:WordPressセキュリティガイド

· 4 min read

はじめに:誰も語らないキャッシュの問題

1週間かけてVarnishを設定し、CDNルールをチューニングし、WordPressサイト全体でフルページキャッシュを有効化しました。キャッシュヒット率は95%に上昇。ページロード時間は200ms未満に低下。すべてが完璧に見えます。

そしてフォームを確認すると——壊れています。

Contact Form 7のすべての送信がバリデーションエラーを返します。WooCommerceのチェックアウトは「セッション切れ」をスローします。管理画面のAJAXコールは静かに失敗します。犯人はWordPressの CSRF nonce ——HTMLに埋め込まれたセキュリティトークンで、12時間ごとに期限切れとなり、ログイン中のユーザーごとにユニークな値になります。

これはエッジケースではありません。WordPressのセキュリティとモダンなキャッシュインフラの間で最も一般的に発生する競合です。深夜2時に「WordPress nonce caching issue」とGoogleで検索した経験があるなら、この苦しみはよくわかるでしょう。

本記事では、WordPress CSRF nonceの仕組みを分解し、キャッシュレイヤーとなぜ競合するのかを正確に説明し、代替案としてステートレスHMACトークンを提示します。サーバー側の状態もユーザー固有のHTMLも必要とせず、同等のCSRF保護を提供するアプローチです。


問題:WordPressのnonceがキャッシュを壊す理由

CSRF Nonceとは?

クロスサイトリクエストフォージェリ(CSRF) は、悪意のあるサイトがユーザーのブラウザを騙して、ユーザーの認証済みセッションに便乗して自サイトにリクエストを送信させる攻撃です。古典的な防御は nonce ——リクエストが自分のページから発信されたことを証明するワンタイムトークンです。

WordPressはこれを wp_create_nonce()wp_verify_nonce() で実装しています。名前に反して、WordPressの「nonce」は真のワンタイムトークンではありません。4つの入力値に紐づいた時間制限付きHMAC署名です。

  1. nonceアクション(自分で定義する文字列、例:'cf7_submit_form_42'
  2. 現在のユーザーID(ログアウト状態のユーザーは 0
  3. 現在のセッショントークン(認証Cookieから取得)
  4. 時間ベースの「tick」(12時間ごとに変化)

WordPress coreの生成ロジックを簡略化するとこうなります。

// wp-includes/pluggable.php を簡略化
function wp_create_nonce( $action = -1 ) {
    $user = wp_get_current_user();
    $uid  = (int) $user->ID;
    $token = wp_get_session_token();
    $tick  = wp_nonce_tick(); // 12時間ごとに変化

    return substr(
        wp_hash( $tick . '|' . $action . '|' . $uid . '|' . $token, 'nonce' ),
        -12,
        10
    );
}

検証では現在のtickと前のtickをチェックするため、各nonceには12~24時間の有効期間があります。

キャッシュ破壊のメカニズム

ここが問題です。入力値をもう一度見てください:ユーザーIDセッショントークンです。

WordPressがフォームを含むページをレンダリングするとき、nonceはHTMLに直接埋め込まれます。

<input type="hidden" name="_wpnonce" value="a3f7b29d01" />

この値はログイン中のユーザーごとに異なります。ログアウト状態のユーザー間でも12時間のウィンドウをまたぐと変わります。つまり:

  • フルページキャッシュは同じHTMLをすべての訪問者に配信します。しかしそのキャッシュされたHTMLに含まれるnonceは、キャッシュをトリガーしたユーザーのものです。他のすべてのユーザーは古い、または間違ったnonceを受け取ります。
  • CDNエッジキャッシュは問題を増幅します。東京のCloudflareエッジノードにキャッシュされたページには、ロンドンのユーザー向けに生成されたnonceが含まれています。そのエッジノードにアクセスするすべての訪問者は、検証に失敗するトークンを受け取ります。
  • リバースプロキシ(Varnish、Nginx FastCGIキャッシュ)も同じ挙動を示します。最初のリクエストがキャッシュを温め、後続のすべてのリクエストはその最初のユーザーのnonceを受け取ります。

結果:フォーム送信が不可解なバリデーションエラーで失敗します。30秒前にロードしたばかりのフォームで「セッション切れ」や「セキュリティチェックに失敗しました」のメッセージが表示されます。

よくある回避策(そしてなぜ不十分なのか)

ほとんどの開発者は以下のいずれかの対処法を取ります。

1. フォームを含むページをキャッシュ対象から除外する。

# Nginx の例:フォームを含むページでキャッシュをバイパス
if ($request_uri ~* "/contact|/checkout") {
    set $skip_cache 1;
}

これは機能しますが、本末転倒です。最も重要なコンバージョンページがキャッシュされないなら、パフォーマンスの恩恵を最も必要な場所で失うことになります。

2. ページロード後にAJAX経由でnonceを読み込む。

// キャッシュされたページのロード後にフレッシュなnonceを取得
fetch('/wp-admin/admin-ajax.php?action=get_fresh_nonce')
  .then(res => res.json())
  .then(data => {
    document.querySelector('[name="_wpnonce"]').value = data.nonce;
  });

これは最も人気のある対処法です。ページはキャッシュ可能なまま、クライアントサイドで有効なnonceを取得します。しかし、独自の問題を抱えています。

  • フォームが送信可能になる前にHTTPラウンドトリップが1回追加される
  • AJAXエンドポイント自体はキャッシュできない(ユーザー固有のデータを返すため)
  • nonceのロード前にユーザーが送信するとレースコンディションが発生する
  • JavaScriptへの依存——JSの実行に失敗するとフォームのセキュリティが失われる

3. Cache-Control: no-store ヘッダーを選択的に使用する。

これはオプション1の手順を増やしただけです。

いずれの解決策も根本原因に対処していません:トークンがユーザーごと・時間ウィンドウごとに変化するサーバー側の状態に依存していることです。


技術的詳解:ステートレスHMACトークン

基本的な考え方

もしトークンがユーザーのセッションに一切依存しないとしたら? サーバーが何も記憶する必要なく、自身の検証に必要なすべての情報をトークン自体が内包しているとしたら?

これがステートレスHMACトークンの原則です。トークンをユーザーセッションに紐づける代わりに、リクエスト自体に紐づけます。正規のブラウザが自然に持ち、攻撃者の偽造リクエストが持たない特性を利用します。

ステートレスCSRFトークンは通常、以下をエンコードします。

  • タイムスタンプ(有効期限用)
  • フォーム識別子(フォーム間のリプレイを防止)
  • HMAC署名(サーバー側の秘密鍵を使って上記フィールドに対して計算)

サーバーは何も保存する必要がありません。現在のユーザーを調べる必要もありません。HMACを検証してタイムスタンプをチェックするだけです。

実装:WordPressステートレストークン

動作する実装はこちらです。

/**
 * ステートレスCSRFトークンを生成する。
 * ユーザーIDなし。セッションなし。データベースなし。完全にキャッシュ可能。
 */
function stateless_csrf_token( string $form_id, int $ttl = 3600 ): string {
    $secret    = defined( 'AUTH_KEY' ) ? AUTH_KEY : wp_salt( 'auth' );
    $timestamp = time();
    $payload   = $timestamp . '|' . $form_id;
    $signature = hash_hmac( 'sha256', $payload, $secret );

    // timestamp.signature の形式でエンコード
    return $timestamp . '.' . $signature;
}

/**
 * ステートレスCSRFトークンを検証する。
 */
function verify_stateless_csrf( string $token, string $form_id, int $ttl = 3600 ): bool {
    $parts = explode( '.', $token, 2 );
    if ( count( $parts ) !== 2 ) {
        return false;
    }

    [ $timestamp, $provided_signature ] = $parts;

    // 有効期限チェック
    if ( ( time() - (int) $timestamp ) > $ttl ) {
        return false;
    }

    // 期待される署名を再計算
    $secret           = defined( 'AUTH_KEY' ) ? AUTH_KEY : wp_salt( 'auth' );
    $payload          = $timestamp . '|' . $form_id;
    $expected_signature = hash_hmac( 'sha256', $payload, $secret );

    // タイミングセーフな比較
    return hash_equals( $expected_signature, $provided_signature );
}

フロントエンドでの使用方法:

// フォームテンプレート内(永久にキャッシュ可能)
$token = stateless_csrf_token( 'contact_form_main' );
echo '<input type="hidden" name="_csrf_token" value="' . esc_attr( $token ) . '" />';
// フォームハンドラ内
$token   = sanitize_text_field( $_POST['_csrf_token'] ?? '' );
$is_valid = verify_stateless_csrf( $token, 'contact_form_main' );

if ( ! $is_valid ) {
    wp_die( 'Invalid or expired security token.', 403 );
}

なぜキャッシュセーフなのか

stateless_csrf_token() が生成するトークンにはユーザーIDもセッショントークンも含まれていません。ほぼ同時にキャッシュされたページをロードする2人の異なるユーザーが、どちらも正しく検証されるトークンを受け取ります。検証がチェックするのは以下のみだからです。

  1. HMAC署名は有効か?(サーバーの秘密鍵が変わっていなければYes。)
  2. タイムスタンプはTTLウィンドウ内か?(キャッシュされたページがTTLより古くなければYes。)

ページ全体——HTML、フォーム、トークンを含めて——をCDNエッジにキャッシュできます。キャッシュのTTLがトークンのTTLより短ければ、すべての訪問者が動作するフォームを受け取ります。

ステートレスアプローチの強化

上記の基本実装はCSRFを防止します。キャッシュ互換性を壊さずにさらに堅牢化するには、追加のシグナルをレイヤーとして重ねることができます。

クライアント生成のフィンガープリントを追加:

// クライアントサイド:フィンガープリントを生成して送信に含める
const fp = btoa(navigator.userAgent + screen.width + screen.height);
document.querySelector('[name="_csrf_fp"]').value = fp;
// サーバーサイド:フィンガープリントを二次的な検証レイヤーに含める
// フィンガープリントは送信時に含まれるため、キャッシュ互換性には影響しない
$fp = sanitize_text_field( $_POST['_csrf_fp'] ?? '' );
if ( empty( $fp ) ) {
    // フィンガープリントなし = ボットからの生HTTPリクエストの可能性
    wp_die( 'Security check failed.', 403 );
}

Origin ヘッダーと Referer ヘッダーをディフェンス・イン・デプスとして活用:

function verify_request_origin(): bool {
    $origin = $_SERVER['HTTP_ORIGIN'] ?? '';
    $referer = wp_parse_url( $_SERVER['HTTP_REFERER'] ?? '', PHP_URL_HOST );
    $site_host = wp_parse_url( home_url(), PHP_URL_HOST );

    if ( ! empty( $origin ) ) {
        return wp_parse_url( $origin, PHP_URL_HOST ) === $site_host;
    }

    return $referer === $site_host;
}

これらの追加は、いずれもユーザーごとの状態を必要としません。


比較表

観点 WordPress Nonce(wp_create_nonce ステートレスHMACトークン
サーバー側の状態 ユーザーセッション+DBルックアップが必要 不要。秘密鍵のみ。
ユーザー固有 はい(ユーザーID+セッショントークンに紐づく) いいえ(フォームID+タイムスタンプに紐づく)
フルページキャッシュ可能 不可。トークンがユーザーごとに異なる。 可能。トークンは全ユーザーで同一。
CDN/エッジキャッシュ可能 不可(AJAXによる回避策なしでは)。 ネイティブに可能。
トークンの有効期間 12~24時間(tickベース) 設定可能なTTL(例:1時間)
リプレイ防止 部分的(同じトークンが12~24時間有効) 部分的(同じトークンがTTLウィンドウ内で有効)
ワンタイム強制 不可(「nonce」という名前にもかかわらず) 不可(強制にはサーバー側ストレージが必要)
ログインユーザーに対応 はい(ユーザーレベルの紐づけあり) はい(ただしユーザーレベルの紐づけなし)
ログアウトユーザーに対応 はい(ただし弱い:全員のユーザーID = 0) はい(全訪問者に対して同等の強度)
GDPRへの影響 セッショントークンに紐づく(個人情報との関連が議論の余地あり) 個人情報の関与なし
実装の複雑さ WordPress coreに組み込み 約30行のカスタムコード
適した用途 管理画面の操作、認証済みエンドポイント 公開フォーム、キャッシュされたページ、APIエンドポイント

ソリューション:適切なツールの選択

WordPress Nonceを使うべきケース

WordPress nonceは認証済みの管理操作に対して引き続き正しい選択です。

  • 管理画面から記事を削除する
  • プラグイン設定を更新する
  • この特定のログインユーザーがリクエストを発行したことを確認する必要があるすべての操作

ここではユーザーへの紐づけはバグではなく機能です。削除リクエストが単にページをロードした誰かではなく、管理者から来たことを確認したいのです。

// 管理画面コンテキスト:nonceが適切
if ( ! wp_verify_nonce( $_POST['_wpnonce'], 'delete_post_' . $post_id ) ) {
    wp_die( 'You do not have permission to delete this post.' );
}

ステートレストークンを使うべきケース

ステートレスHMACトークンは公開向けのキャッシュフレンドリーなシナリオでより適した選択です。

  • キャッシュされたランディングページのお問い合わせフォーム
  • ニュースレター登録フォーム
  • 高トラフィック記事のコメントフォーム
  • CDN経由でログアウト状態の訪問者に配信されるすべてのフォーム

これらのケースでは、紐づける意味のある「ユーザー」が存在しません。ログアウト状態の訪問者に対するWordPress nonceは user_id = 0 と空のセッショントークンを使用するため、「ユーザー紐づけ」はセキュリティ上ゼロの追加的価値しか提供しません。キャッシュの頭痛の種はすべて抱え込みながら、セキュリティ上のメリットは一切得られないのです。

アーキテクチャの判断

シンプルなルールとして考えてください。

  • wp-admin の背後? wp_nonce を使う。
  • CDNの前面? ステートレストークンを使う。

ほとんどのWordPressサイトでは、管理画面がnonceを使い、公開サイトがステートレストークンを使います。両者は競合なく共存します。

実践的な統合

Contact Form 7のエクステンションやカスタムフォームハンドラを構築する場合、ステートレスアプローチはスムーズに統合できます。

add_filter( 'wpcf7_form_hidden_fields', function( $fields ) {
    $fields['_stateless_csrf'] = stateless_csrf_token( 'cf7_global' );
    return $fields;
});

add_filter( 'wpcf7_validate', function( $result, $tags ) {
    $token = sanitize_text_field( $_POST['_stateless_csrf'] ?? '' );
    if ( ! verify_stateless_csrf( $token, 'cf7_global' ) ) {
        $result->invalidate(
            $tags[0],
            'Security verification failed. Please reload the page.'
        );
    }
    return $result;
}, 10, 2 );

これにより、JavaScriptへの依存ゼロ、追加HTTPリクエストゼロで、フルキャッシュされたページ上でCSRF保護を実現できます。


Samurai Honeypot for Formsについて

Contact Form 7を使用していて、自前実装なしにこの種のステートレスでキャッシュ互換のセキュリティを求めるなら、Samurai Honeypot for Forms がこのアプローチをすぐに使える形で提供しています。ステートレスHMAC検証に加え、ポリモーフィック・ハニーポットフィールドやクライアントサイドのProof of Workチャレンジを組み合わせており、AJAXによるnonce回避策なしにアグレッシブなキャッシュレイヤーの背後で動作するよう設計されています。プロジェクトごとにnonceとキャッシュの対立と戦うことに疲れたなら、検討に値するでしょう。


まとめ

  1. WordPress nonceは真のnonceではありません。 ユーザーセッションに紐づいた時間制限付きHMACトークンです。管理操作向けに設計されたものであり、公開フォーム向けではありません。

  2. nonceはフルページキャッシュを壊します。 ユーザー固有のデータをHTMLレスポンスに埋め込むためです。あらゆる回避策(AJAXロード、キャッシュ除外)はパフォーマンスまたは信頼性のトレードオフを伴います。

  3. ステートレスHMACトークンは、ユーザーセッションやサーバー側の状態を必要とせず、公開フォームに対して同等のCSRF保護を提供します。本質的にキャッシュセーフです。

  4. 両方を使いましょう。 認証済み管理操作にはnonce。公開フォームにはステートレストークン。それぞれ異なる問題を解決します。

  5. HMAC署名の比較には常に hash_equals() を使用してください。 暗号化処理の比較に ===strcmp() を使ってはいけません。


参考資料:

すべてのコラム