2026年6月26日
2026年6月26日
WordPressでWebhook受信エンドポイントをセキュアに実装する方法(署名検証・リプレイ対策・重複排除)
外部サービスからのWebhookをWordPressで受け取るなら、REST APIにPOST専用のルートを作り、permission_callback で署名(HMAC)とタイムスタンプを検証し、処理本体は非同期に回すのが安全で扱いやすい形です。この記事では、mu-pluginとして置ける受信エンドポイントのコード例と、署名の検証・リプレイ(再送攻撃)対策・重複イベントの排除・秘密鍵の置き場所・テスト方法までを順に説明します。
Webhookの署名方式(ヘッダー名、署名対象の文字列、sha256= のような接頭辞の有無)は送信元サービスごとに違います。ここでは汎用的な形で書くので、実際に使う際は送信元の開発者ドキュメントにある仕様に合わせて読み替えてください。各サービスの署名方式は変更されることもあるため、導入時点の最新の仕様を確認します(2026年10月現在)。
受信エンドポイントで守るべきこと
Webhookの受信口はURLさえ分かれば誰でもPOSTできます。URLを推測しにくくしても、それだけでは防御になりません。最低限、次の4点を押さえます。
- 送信元の確認: 共有シークレットで計算したHMAC署名を照合し、改ざんやなりすましを弾く
- リプレイ対策: 署名つきのタイムスタンプを確認し、古いリクエストの使い回しを拒否する
- 冪等性(重複排除): 送信元は失敗時に同じイベントを再送するため、イベントIDで二重処理を防ぐ
- 即時応答: 重い処理をリクエスト内で行うと送信元がタイムアウトして再送を繰り返すので、受け付けたらすぐ200を返し、処理はバックグラウンドへ回す
IPアドレスによる制限は補助的な手段として考えます。送信元が公開するIP範囲は変更されることがあり、CDNやリバースプロキシの後ろでは REMOTE_ADDR が送信元を表さないためです。
事前準備:シークレットをwp-config.phpに置く
送信元の管理画面で発行される署名用シークレットは、データベースのオプションやテーマのファイルではなく、wp-config.php の定数か環境変数で渡します。管理画面に表示されず、エクスポートしたDBダンプにも含まれないためです。
// wp-config.php(「編集が必要なのはここまでです」の行より上に追記)
define( 'MY_WEBHOOK_SECRET', getenv( 'MY_WEBHOOK_SECRET' ) ?: 'your-webhook-secret' );
wp-config.php をGitで管理している場合は、実際の値をコミットしないでください。環境変数で渡せるサーバーなら getenv() を使い、上の your-webhook-secret はダミーのままにしておきます。
エンドポイントのコード例
コードは wp-content/mu-plugins/my-webhook-receiver.php に置く想定です。mu-pluginはテーマを切り替えても止まらず、管理画面から誤って無効化されることもないので、受信処理の置き場所に向いています。作業前にサイトのバックアップを取り、まずステージング環境で試してください。
<?php
/**
* Plugin Name: My Webhook Receiver
* Description: 署名検証つきのWebhook受信エンドポイント
*/
defined( 'ABSPATH' ) || exit;
add_action( 'rest_api_init', function () {
register_rest_route(
'my-webhook/v1',
'/receive',
array(
'methods' => 'POST',
'callback' => 'my_webhook_handle',
'permission_callback' => 'my_webhook_verify',
)
);
} );
/**
* 署名とタイムスタンプを検証する。true 以外を返すと本体は実行されない。
*/
function my_webhook_verify( WP_REST_Request $request ) {
if ( ! defined( 'MY_WEBHOOK_SECRET' ) || '' === MY_WEBHOOK_SECRET ) {
return new WP_Error( 'webhook_not_configured', 'Webhook is not configured.', array( 'status' => 500 ) );
}
$body = $request->get_body(); // 生のリクエスト本文(署名はこれに対して計算されている)
if ( strlen( $body ) > 512 * 1024 ) { // 512KBを超える本文は受け付けない
return new WP_Error( 'webhook_too_large', 'Payload too large.', array( 'status' => 413 ) );
}
$timestamp = (string) $request->get_header( 'x-webhook-timestamp' );
$signature = (string) $request->get_header( 'x-webhook-signature' );
if ( '' === $timestamp || '' === $signature || ! ctype_digit( $timestamp ) ) {
return new WP_Error( 'webhook_bad_signature', 'Invalid signature.', array( 'status' => 401 ) );
}
// 5分(300秒)より古い、または未来すぎるリクエストは拒否する
if ( abs( time() - (int) $timestamp ) > 300 ) {
return new WP_Error( 'webhook_expired', 'Request expired.', array( 'status' => 401 ) );
}
$expected = hash_hmac( 'sha256', $timestamp . '.' . $body, MY_WEBHOOK_SECRET );
if ( ! hash_equals( $expected, $signature ) ) {
return new WP_Error( 'webhook_bad_signature', 'Invalid signature.', array( 'status' => 401 ) );
}
return true;
}
/**
* 受け付けて、処理はバックグラウンドに回す。
*/
function my_webhook_handle( WP_REST_Request $request ) {
$data = json_decode( $request->get_body(), true );
if ( ! is_array( $data ) || empty( $data['id'] ) || empty( $data['type'] ) ) {
return new WP_Error( 'webhook_bad_payload', 'Invalid payload.', array( 'status' => 400 ) );
}
$event_id = preg_replace( '/[^A-Za-z0-9_\-]/', '', (string) $data['id'] );
$key = 'my_wh_' . md5( $event_id );
// 同じイベントIDを受け取り済みなら、処理せず200を返す(再送を止めるため)
if ( false !== get_transient( $key ) ) {
return rest_ensure_response( array( 'status' => 'duplicate' ) );
}
set_transient( $key, $data, DAY_IN_SECONDS );
wp_schedule_single_event( time(), 'my_webhook_process_event', array( $key ) );
return rest_ensure_response( array( 'status' => 'accepted' ) );
}
add_action( 'my_webhook_process_event', function ( $key ) {
$data = get_transient( $key );
if ( ! is_array( $data ) ) {
return;
}
switch ( sanitize_key( $data['type'] ) ) {
case 'order_paid':
// ここで注文の更新などを行う。値は検証・サニタイズしてから使う。
break;
}
// ペイロード本体は残さず、受信済みの印だけ残して重複判定に使う
set_transient( $key, 'done', DAY_IN_SECONDS );
} );
エンドポイントのURLは https://example.com/wp-json/my-webhook/v1/receive になります。パーマリンク設定が「基本」のサイトでは /?rest_route=/my-webhook/v1/receive の形になるので、送信元に登録するURLを間違えないようにしてください。
コードのポイントと、つまずきやすい所
permission_callbackで検証する理由
permission_callback はWordPress 5.5以降、ルート登録時に指定が求められる項目で、省略すると開発者向けの警告が出ます。Webhookはログインユーザーではないので current_user_can() は使えませんが、ここに署名検証を置くと「検証に通らないリクエストは本体の処理に到達しない」構造になります。__return_true を指定して本体の中で検証する書き方もできますが、後から処理を足したときに検証の前に処理を書いてしまう事故を防ぐ意味で、分けておくほうが安全です。
署名は「生の本文」に対して計算する
署名の照合に json_decode() した後の配列や $request->get_params() を使うと、空白やキーの順序が変わって一致しなくなります。$request->get_body() で受け取った文字列そのものを使ってください。比較には === ではなく hash_equals() を使います。文字列比較の処理時間の差から正しい署名を推測される、タイミング攻撃への対策です。
ヘッダー名は get_header() が大文字・小文字やハイフンの違いを吸収して取得します。送信元によっては署名に sha256= のような接頭辞が付く、タイムスタンプが署名ヘッダーの中に含まれる、といった違いがあるので、$expected を組み立てる行を仕様に合わせて書き換えます。
重複排除と非同期処理
送信元は、応答が遅い・200以外が返る、といった場合に同じイベントを再送します。上の例ではイベントIDをキーにしたトランジェントで受信済みかを判定し、重複には処理をせず200を返しています。トランジェントは厳密なロックではないため、同時に同じイベントが届くとまれに両方が通る可能性があります。決済など二重処理が許されない用途では、イベントIDにUNIQUE制約を付けた専用テーブルに記録する方法を検討してください。
非同期処理には wp_schedule_single_event() を使っています。WP-Cronはサイトへのアクセスを契機に動くため、アクセスが少ないサイトでは実行が遅れます。サーバーのcronから wp cron event run --due-now を定期実行するか、WooCommerceなどが同梱しているAction Schedulerライブラリを使うと安定します。
ペイロードの中身を信用しすぎない
署名検証に通っても、本文の値はそのまま使わず、型の確認とサニタイズを行ってから使います。決済のように重要なイベントでは、Webhookは「何かが起きた」という通知として扱い、送信元のAPIで最新の状態を取得し直してから処理する設計が多くのサービスで推奨されています。
運用面で確認すること
- ログ: 失敗理由とイベントID、受信時刻は記録しますが、ペイロード全体やシークレット、署名値はログに残しません。個人情報を含むWebhookなら、保存期間とプライバシーポリシーの記載も確認します。
- セキュリティプラグインとの干渉: 未ログインユーザーのREST APIを遮断する設定やプラグインがあると、Webhookも401や403で弾かれます。その場合は該当ルートだけ許可する設定があるか確認します。
- キャッシュとWAF: CDNやWAFが
/wp-json/へのPOSTをブロック・キャッシュしていないかを確認します。 - 流量制限: 不正な大量アクセスへの対策は、PHPに到達する前のWebサーバー(nginxの
limit_reqなど)やWAFで行うほうが負荷を抑えられます。 - シークレットのローテーション: 送信元でシークレットを再発行したら、
wp-config.phpの値も同時に更新します。切り替え期間に新旧両方を受け付ける実装にしておくと、取りこぼしを防げます。
curlで動作を確認する
送信元の管理画面にテスト送信機能があればそれを使うのが手軽ですが、手元で署名を作って確認することもできます。
SECRET='your-webhook-secret'
TS=$(date +%s)
BODY='{"id":"evt_test_001","type":"order_paid"}'
SIG=$(printf '%s' "${TS}.${BODY}" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //')
curl -i -X POST 'https://example.com/wp-json/my-webhook/v1/receive' \
-H 'Content-Type: application/json' \
-H "X-Webhook-Timestamp: ${TS}" \
-H "X-Webhook-Signature: ${SIG}" \
--data "$BODY"
確認したいのは次の4パターンです。正しい署名で accepted、同じコマンドを再実行して duplicate、SECRET を変えて401、TS を10分前の値にして401になれば、検証は意図どおり動いています。予約されたイベントは wp cron event list で確認できます。
まとめ
- Webhook受信は
register_rest_route()でPOST専用ルートを作り、permission_callbackで署名を検証する - 署名は生の本文で計算し、
hash_hmac()とhash_equals()で照合する。タイムスタンプでリプレイを防ぐ - イベントIDで重複を排除し、すぐ200を返して処理はWP-CronやAction Schedulerに回す
- シークレットは
wp-config.phpの定数や環境変数に置き、Gitやログに残さない - 署名方式は送信元ごとに違うので、送信元のドキュメントに合わせて検証部分を調整する