Stripe Webhookの署名検証|安全運用のための完全ガイド

目次

1. Stripe Webhookと署名検証の基本

Stripe Webhook は、決済成功・サブスクリプション更新・請求書発行など、Stripe 内で発生したイベントを自社サーバーへ自動通知する仕組みです。便利な一方で、Webhook エンドポイントはインターネット上に公開されるため、第三者が不正リクエストを送る余地もあります。そのため、Stripe はすべての Webhook リクエストに Stripe-Signature ヘッダーを付与しており、受信側のアプリで「本当に Stripe から送信されたものか?」を検証する必要があります。

署名検証は、安全にイベント処理を行ううえで“最初の防御ライン”となります。特に、決済状態・ユーザーのサブスク状態など重要なデータを更新する処理では、署名検証を省略するとリスクが高くなります。Stripe 公式も「署名検証は推奨ではなく必須レベルの要件」と明確にしています。

Webhookが攻撃対象になりやすい理由

Webhook エンドポイントは「外部から自由に叩ける API」という構造を持つため、攻撃者にとって狙いやすいポイントです。特に以下の性質がリスクを高めます。

  • 認証なしでアクセス可能:多くの Webhook は OAuth などの認証を使わず、署名検証のみで保護します。
  • 重要処理に直結しやすい:支払いステータス更新・顧客アクション処理などに繋がるため、不正イベントでも処理されてしまうと重大な影響が出ます。
  • リプレイ攻撃に弱い:過去のイベントを再送されると、二重処理が起きる危険性があります。

これらの理由から、Webhook は通常の API 以上にセキュアな設計が求められます。署名検証はそのための基本要素です。

Stripe-Signatureヘッダーの役割

Stripe-Signature ヘッダーには、Stripe が Webhook リクエストに対して生成した HMAC ベースの署名が格納されています。形式としては次のようになっています。

Stripe-Signature: t=1732612345,v1=abcdef1234567890...
  • t — Stripe が署名を作成した UNIX タイムスタンプ
  • v1 — エンドポイントシークレットを使って作られた署名(HMAC-SHA256)

アプリ側では、受け取った “生” のリクエストボディと Stripe-Signature の値を使って同じ HMAC 計算を行い、結果が一致すれば「正しい Stripe からのリクエスト」と判定します。タイムスタンプが古すぎる場合はリプレイ攻撃として拒否できます。

署名検証の仕組み(HMACとタイムスタンプ)

Stripe の署名風は、HMAC(Hash-based Message Authentication Code)という標準的な暗号技術を利用しています。これは「共有された秘密鍵」と「メッセージ本体」を元に認証コードを生成し、メッセージの改ざんがないことを確認する仕組みです。

Stripeの場合、HMAC の入力となるデータは次の形式です。

t + "." + raw_body

つまり、Stripe が署名を作成した時刻(t)とリクエスト本体(raw_body)の結合文字列が元になっています。これは2つの効果があります。

  • 改ざん防止:raw_body が途中で書き換えられると署名一致しなくなる。
  • リプレイ攻撃対策t が古すぎると検証エラーになる。

Stripe 公式ライブラリを使う場合、この HMAC 計算を自前で書く必要はありません。PHP では \Stripe\Webhook::constructEvent に raw body・ヘッダー・シークレットを渡すだけで検証が行われます。実務では「raw body が正しく取れているか」「シークレットを間違えていないか」が主な注意点となります。

公式推奨フロー:署名検証の正しい実装手順

Stripe Webhook の署名検証は、公式ドキュメントが示す「推奨フロー」に沿うことで安定した実装ができます。ここでは、実務者がよくつまずくポイントを補足しつつ、全体の流れを整理します。

大まかな流れは以下の3ステップです。

  1. Stripe ダッシュボードで Webhook エンドポイントを作成 → エンドポイントシークレット(whsec_…)を取得
  2. アプリ側で raw body と signature を取得できるように設定する
  3. 公式ライブラリの constructEvent に raw body・signature・シークレットを渡して検証

特に 「raw body を正確に取れるかどうか」 が検証成功に大きく影響し、素の PHP、Laravel、CodeIgniter など、フレームワークごとに設定が違います。Stripe 自身も「Webhook ルートでは JSON パーサーを適用しないこと」を何度も強調しています。

エンドポイントシークレットの取得方法

Stripe ダッシュボードでは、以下の流れでシークレットを確認できます。

  1. Stripe ダッシュボード →「開発者」→「Webhook」へ移動
  2. 対象エンドポイント(URL)を選択
  3. 「署名シークレット」欄で whsec_... が表示される

運用の現場で特に注意したいのは次の3点です。

  • テストモードと本番モードで別のシークレットが発行される
  • URLが1文字違うだけでも別エンドポイント扱いになる
  • シークレットのローテーション時は旧・新どちらも一時的に有効になる

開発でありがちなミスとして、「本番のエンドポイントシークレットを staging 環境に設定してしまう」「URL を変更したが古いエンドポイントを残したまま」などがあります。環境変数名にも _TEST / _LIVE を付けると安全です。

署名検証の基本コード構造(PHPの擬似コード)

Stripe Webhook の署名検証は、PHP では次のようなパターンで実装します。

// 1. raw body を取得
$payload = file_get_contents('php://input'); // パース前の生データ

// 2. Stripe-Signature ヘッダーを取得
$sig_header = $_SERVER['HTTP_STRIPE_SIGNATURE'] ?? '';

// 3. エンドポイントシークレット
$secret = getenv('STRIPE_WEBHOOK_SECRET');

// 4. Stripe 公式の検証関数
$event = \Stripe\Webhook::constructEvent(
    $payload,
    $sig_header,
    $secret
);

// 5. 検証済みイベントを処理
handle_event($event);

実務でつまずきやすいのは「raw body が JSON パーサーやフレームワーク側の処理に触られてしまい破損する」点です。改行・空白が1つ変わっただけでも HMAC 署名は一致しません。そのため、“Webhook のルートだけ生のボディを扱う”という設計が必要になります。

PHPフレームワーク別の注意点

Stripe Webhook は PHP フレームワークごとに取り扱いが異なるため、代表的な注意点をまとめます。

  • 素のPHP:必ず file_get_contents('php://input') で生ボディを取得し、その文字列を \Stripe\Webhook::constructEvent() に渡す。
  • Laravel:一部バージョンでリクエストボディが自動的に JSON デコードされることがあるため、Webhook ルートでは php://input から直接取得する実装にする。
  • CodeIgniter / Slim など:フレームワークの Request オブジェクトが返す body が「生データ」かどうかを確認し、必要に応じて php://input を利用する。

Stripe Webhook は「少しの設定ミスで検証エラーが続く」ことが非常に多いため、フレームワーク固有の要件を抑えておくことが大切です。

PHPでの実装例

Stripe Webhook の署名検証は、「raw body を取得し、Stripe-Signature を読み取り、\Stripe\Webhook::constructEvent() に渡す」という共通パターンで動きます。本記事では、バックエンドを PHP で構築するケースに絞り、最小構成の署名検証コードを紹介します。他言語でも基本的な考え方は同じですが、実装例は PHP に特化します。

PHP はPOST データを $_POST で扱う文化がありますが、Stripe Webhook の署名検証では php://input を使います。こちらが生のリクエストボディです。

<?php

require 'vendor/autoload.php';

\Stripe\Stripe::setApiKey(getenv('STRIPE_API_KEY'));

$endpoint_secret = getenv('STRIPE_WEBHOOK_SECRET');

// 生ボディを取得
$payload = @file_get_contents('php://input');
$sig_header = $_SERVER['HTTP_STRIPE_SIGNATURE'] ?? '';

try {
    $event = \Stripe\Webhook::constructEvent(
        $payload,
        $sig_header,
        $endpoint_secret
    );
} catch (\Stripe\Exception\SignatureVerificationException $e) {
    error_log('Signature verification failed: ' . $e->getMessage());
    http_response_code(400);
    exit();
}

// event データを処理
// 例:
// if ($event->type === 'invoice.payment_succeeded') {
//     // 請求書支払い成功時の処理
// }

http_response_code(200);

実務の注意点:

  • $_POST を使うと内容が変化して署名一致しない。
  • Apache や Nginx のモジュールで自動的に改行変換されるケースもあるため注意。
  • ログは error_log に出すと調査が容易。

これらの実装パターンを理解しておくことで、署名検証エラー時の原因切り分けが大幅に速くなります。

よくある署名検証エラーと解決チェックリスト

Stripe Webhook のトラブルで最も多いのが「署名検証に失敗する」という現象です。実際は、原因がある程度パターン化されており、チェックリストを順番に確認することでほぼ確実に解決できます。

ここでは実務経験に基づき、開発者が頻繁に遭遇する原因を整理します。

タイムスタンプずれの問題

Stripe の署名にはタイムスタンプ(t= 値)が含まれており、現在時刻との差が大きいと “リプレイ攻撃の可能性” と判断され、検証エラーになります。

  • サーバーの時刻がズレている(Docker・VM・ローカル環境で起きやすい)
  • イベントを Stripe 側で手動再送した際、古いイベントが混ざる
  • 検証 tolerance を厳しく設定しすぎている

対策としては、NTP による時刻同期がもっとも効果的です。また、Stripe 側のイベントが遅延して飛んでくるケースも稀にあるため、許容範囲(tolerance)はデフォルトの 300 秒のままが安全です。

raw body を渡せていない問題

署名検証エラーの 7〜8割はこれが原因といっても過言ではありません。JSON パーサーを通したり、ログフィルタで改行が置換されたりすると、HMAC の一致が崩れます。

  • アプリケーションフレームワークが自動的に JSON デコードしている
  • API Gateway が body を base64 変換している
  • 監視ツールやミドルウェアが body をフィルタしている
  • 文字コードが UTF-8 以外になっている

原因調査のコツとして、“Stripe 受信時の raw body をそのままログに出す” を行うと、改変箇所がすぐにわかります。

シークレット取り違え問題

意外と多いのが、エンドポイントシークレットの混在によるエラーです。

  • whsec_... の値がテスト/本番で逆になっている
  • URL を変更したのに古いエンドポイントのシークレットを使っている
  • ローテーションして新旧が混ざった状態になっている

本番運用では、環境変数を次のように分けると安全です。

STRIPE_WEBHOOK_SECRET_TEST
STRIPE_WEBHOOK_SECRET_LIVE

また、チーム開発の際は「どの URL のシークレットなのか」を README や Notion に明記しておくと事故が起きにくくなります。

その他のデバッグポイント

それでも原因がわからない場合は、次の点も確認してください。

  • Webhook URL が https でない(ローカル環境で一部サービスが拒否する)
  • ロードバランサーが body を圧縮・解凍している
  • ペイロードサイズの上限に引っかかっている
  • Stripe CLI テストイベントと本番イベントで内容が異なる

Stripe Webhook は一見複雑ですが、原因を1つずつ潰していくと必ず改善できます。実務では「生ボディ」と「シークレットの確認」がもっとも効果的なデバッグ手順です。

安全にWebhookを運用するための追加セキュリティ対策

Stripe Webhook は署名検証だけでも一定の安全性を確保できますが、実際の運用では「周辺のセキュリティ設定」も重要です。システム構成やネットワーク経路によっては、署名検証前にリクエストが破損したり、意図しないアクセスを許可してしまう場合があるため、複数のレイヤーで防御を行うことが推奨されます。

IP制限・WAF設定の考え方

Stripe Webhook は公開エンドポイントですが、環境によっては IP フィルタリングや WAF(Web Application Firewall)を利用して “余計な通信” をブロックできます。Stripe は固定IPではないため「完全なホワイトリスト管理」は難しいものの、次のような設定で攻撃リスクを減らせます。

  • ルール1:POST 以外のメソッドはすべて拒否する
  • ルール2:User-Agent に Stripe 以外の明らかな Bot が含まれる場合はブロック
  • ルール3:大量リクエストを送るクライアントのレート制限を行う

特にクラウド WAF(AWS WAF、Cloudflare WAF、GCP Cloud Armor)を導入している場合は、「Webhook エンドポイントだけ制限を少し緩め、他は厳格に閉じる」という構成が現実的です。署名検証こそ最終防御なので、WAF のブロックや変換機能が raw body を改変しないよう、例外ルールを調整する必要があります。

接続障害時のリトライ設計

Stripe Webhook は「送信側(Stripe)が自動リトライする」という特徴があります。受信側サーバーが 2xx を返さなければ、数十秒〜数分間隔で再送が行われます。これを正しく理解していないと、イベントの“二重処理”が発生する恐れがあります。

  • ポイント1:イベントは idempotent(同じ event.id が再送される)
  • ポイント2:受信側で event.id を保存して重複処理を避ける
  • ポイント3:処理に失敗したら 400 や 500 を返し、Stripe に再送させる

特に決済・サブスク更新・請求書発行などの処理は、誤って二重実行するとユーザーのステータスが不整合になるため、実務では event.id を “処理済みフラグ” として DB に保存する方法がよく使われます。

本番・テスト環境の分離とシークレット管理

Stripe Webhook は「テストモード」と「本番モード」で別のエンドポイント・別のシークレットが発行されます。実務ではこの分離ミスが最も多く、特に staging や preview 環境を用意している場合は、以下のようなルールを明確にしておくと安全です。

  • 環境変数名を厳格に分ける:
    STRIPE_WEBHOOK_SECRET_TESTSTRIPE_WEBHOOK_SECRET_LIVE
  • シークレットは必ず環境変数化し、リポジトリに含めない
  • エンドポイント URL の末尾(/webhook など)を環境ごとに変える

また、シークレットをローテーションする場合は、新旧のシークレットが “一時的に両方有効” になるため、サーバー側の設定反映がずれないよう注意が必要です。シークレット管理はミスが事故に直結するため、ドキュメント化してチームで統一することを推奨します。

運用Tips:監視・ログ設計・テスト方法

Stripe Webhook を安定運用するためには、「署名検証が成功したか」「イベントが正しく処理されているか」を継続して監視する仕組みが不可欠です。運用が疎かになると、数日〜数週間後に「特定のイベントだけ処理されていなかった」という事態が起きやすくなります。

ここでは現場で有効だった監視方法・ログ設計・テストツールを紹介します。

Stripe CLIでのWebhookテスト

Stripe CLI は Webhook 開発に必須のツールです。ローカル開発環境へ安全に Webhook を送信でき、署名検証も本番と同じ方式で行われます。

stripe listen --forward-to localhost:3000/webhook
  • 本番と同じ署名検証が行われる
  • イベント種別を選択して送信できる(invoice.paid など)
  • 実際の raw body を確認できる

Webhook 実装を始める前に、まず「listen → send event」で署名検証が通るか確認するのが最短ルートです。

ログの残し方と異常検知

Webhook のトラブル調査では、ログが“決定的な手がかり”になります。特に署名検証周辺は原始的な方法が最も効果的です。

  • Stripe-Signature ヘッダーの生値を記録する
  • raw body をそのまま記録(改行含む)
  • 検証成功/失敗のログを分ける
  • event.id・event.type を時系列で残す

異常検知では、次のような監視が効果的です。

  • 一定時間 event が届いていない(Stripe 障害 or 自社障害)
  • 検証失敗が一定回数を超えた
  • event.type の偏りが異常(特定イベントだけ処理されていない)

障害時の手動復旧ポイント

Stripe Webhook は、自動再送によりある程度は耐障害性がありますが、運用では「手動復旧」が必要になる局面があります。

  • Stripe ダッシュボード → Webhook → イベント → 再送する
  • event.id 単位で処理済みかどうかをDBで確認する
  • 大量再送が必要な場合は Stripe CLI の send コマンドを利用

特にサブスクリプション更新や請求関連のイベントが処理漏れすると、顧客ステータスが不整合になりやすいため、復旧手順を事前にまとめておくと安心です。

よかったらシェアしてね!
  • URLをコピーしました!

この記事を書いた人

システム開発やWeb制作をして15年以上。
このブログでは、これから起業したい人や小さくビジネスを始めたい人に役立つ情報を発信しています。
Stripeを使った販売方法や、ノーコードでサブスクを作るコツなど、
「やってみたい」を形にするためのヒントをお届けしています。

コメント

コメントする

目次