Webhook 受信側のベストプラクティス - 署名検証・リトライ・冪等性・順序

Webhook 受信側のベストプラクティス - 署名検証・リトライ・冪等性・順序

作成日:
読了:9
更新日:

「Webhook を受けるだけ」と思って雑に実装すると、偽イベントで在庫が動く・二重課金・リトライ暴発といった事故に直結します。Webhook は受信側の作法が9割です。この記事では、安全で堅牢な受信エンドポイントの作り方を、Stripe・GitHub・Standard Webhooks を一次ソースに整理します。

Webhook とは

送信側(プロバイダ)が、イベント発生時に受信側のエンドポイントへ HTTP POST でプッシュ通知する仕組みです。受信側がAPIを繰り返し叩くポーリングが不要になります(決済イベント、GitHubのpush/PRなどが典型)。設計の責任は受信側に多く乗ります。

1. 署名検証(最重要)

検証しないと、誰でも偽のイベントを送れます。送信元の真正性と改ざん検知のため、HMAC-SHA256 による署名を検証します。

  • 共有シークレットで HMAC-SHA256(secret, signed_payload) を計算し、ヘッダの署名と一致するか確認(例: Stripe-Signature / X-Hub-Signature-256
  • リプレイ攻撃対策: 署名対象にタイムスタンプを含め、現在時刻から一定(例: 5分)以上ずれていたら拒否
  • 必ず「生のボディ(raw bytes)」で検証する。JSONをパース→再シリアライズするとキー順や空白が変わって署名が一致しない
  • 比較は 定数時間比較hmac.compare_digest 等)。==タイミング攻撃に弱い
HMAC 署名検証(Stripe風)
import hmac, hashlib, time
 
def verify(raw_body: bytes, sig_header: str, secret: str) -> bool:
    parts = dict(p.split("=", 1) for p in sig_header.split(","))
    ts, received = parts.get("t", ""), parts.get("v1", "")
    # リプレイ対策: 5分ウィンドウ
    if abs(time.time() - int(ts)) > 300:
        return False
    signed = f"{ts}.".encode() + raw_body          # timestamp + "." + raw body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, received)  # 定数時間比較

NOTE

Standard Webhooks という共通仕様もあり、webhook-id / webhook-timestamp / webhook-signature の3ヘッダで HMAC(や Ed25519)署名を標準化しています。OpenAI・Anthropic・Twilio など多数が採用。プロバイダ独自方式でも考え方は同じです。

2. リトライと冪等性

ほとんどの Webhook は at-least-once(少なくとも1回)配信です。「同じイベントが複数回来る」前提で設計します(exactly-once は分散システムの原理上、実現できません)。

  • 送信側は失敗時に指数バックオフでリトライ(Stripe は本番で最大3日間など)
  • 受信側は冪等に処理する。イベントIDを一意キーにして処理済みを記録し、重複はスキップ
  • DBのユニーク制約 + ON CONFLICT DO NOTHINGなら、競合状態下の重複も防げる

冪等性そのものの考え方はREST API設計と冪等性も参照してください。

3. 配信順序は保証されない

イベントの到着順は保証されません。 Stripe も「subscription.created より先に subscription.deleted が届くことがある」と明言しています。

  • 順序に依存しない設計にする
  • 必要ならイベントのタイムスタンプ/シーケンスで並べ替える
  • 足りない情報はプロバイダのAPIを叩いて補完する(Webほどの payload を信じすぎない)

4. 受信エンドポイントの作法

ここが堅牢さの肝です。まず素早く 2xx を返し、重い処理は非同期に逃がします。

受信の流れ
1. 生のボディ(bytes)を取得(JSONパースより前に)
2. 署名検証(失敗なら 400)
3. JSON をパース
4. 冪等チェック(event_id が処理済みなら 200 を返して終了)
5. すぐに 200 OK を返す
6. 非同期キュー(SQS / Celery / BullMQ 等)へ積む
7. ワーカーがビジネスロジックを実行
  • 即時 2xx: GitHub は10秒以内に 2xx を要求。重い処理を同期実行するとタイムアウト→リトライ暴発
  • 生ペイロードを保存(デバッグ・再処理・監査に効く)
  • 未知のイベント種別は無視して 2xxを返す(将来の新イベントで壊れない)。500 を返すと延々リトライされる

5. セキュリティ補足

  • HTTPS 必須(本番でHTTP不可)
  • シークレットのローテーション: 新旧を一定期間併存させて無停止で切替(Stripeは最大24時間)
  • IP許可リストは多層防御の一層。署名検証の代わりにはならない
  • URLの秘匿性に頼らない(推測されない前提で設計しない)
  • フレームワークの CSRF保護からWebhookエンドポイントを除外(弾かれて全部403になる事故が定番)

トークン認証やレート制限と合わせるならJWTレート制限、ステータスの返し方はHTTPステータスコードも参照。

よくある落とし穴

  • パース後のボディで署名検証(再シリアライズで不一致)→ 生バイト列で検証
  • 同期処理でタイムアウト → まず 2xx、処理は非同期
  • 重複未対応(at-least-onceを忘れる)→ イベントIDで冪等化
  • 順序を前提にした処理 → 順序非依存に
  • == で署名比較 → 定数時間比較
  • タイムスタンプ検証を省略 → リプレイ可能になる
  • 未知イベントで500 → 無視して2xx

まとめ

  • 署名検証は必須: HMAC-SHA256+生ボディタイムスタンプ(リプレイ対策)定数時間比較
  • at-least-once 前提で冪等に。イベントIDで重複排除(ユニーク制約)
  • 配信順序は保証されない。順序非依存にし、足りなければAPIで補完
  • 受信は即時 2xx →非同期処理。生ペイロード保存、未知イベントは2xx
  • HTTPS・シークレットローテーションCSRF除外を忘れない

Webhook は「受け取って終わり」ではなく、偽物・重複・順不同・タイムアウトを捌けて初めて実戦投入できます。検証 → 冪等 → 即時2xx → 非同期——この型を守れば、ほとんどの事故は防げます。

参考リンク

REST API設計の基礎と冪等性 - HTTPメソッドの意味と Idempotency-Key

REST API設計の基礎と冪等性 - HTTPメソッドの意味と Idempotency-Key

10

REST API設計の基礎を、HTTPメソッドの意味(safe / idempotent)から整理します。リソース指向のURL設計、GET/POST/PUT/PATCH/DELETE の使い分けと冪等性の早見表、なぜ冪等性がリトライや二重課金防止に重要か、POST を安全にリトライする Idempotency-Key の仕組み、ステータスコードの使い分け、RFC 9457 のエラー形式まで、RFC 9110・MDN・Stripe を一次ソースにまとめます。

JWT(JSON Web Token)の仕組みと正しい使い方 - 署名・検証とセキュリティの落とし穴

JWT(JSON Web Token)の仕組みと正しい使い方 - 署名・検証とセキュリティの落とし穴

12

JWT(JSON Web Token)を実務目線で整理します。header.payload.signature の3部構造と Base64URL、iss/sub/aud/exp などのクレーム、HS256 と RS256/ES256 の使い分け、検証の流れ、そして alg:none 攻撃・アルゴリズム混同・「JWT は暗号化ではないので中身は誰でも読める」という誤解・失効の難しさといった落とし穴まで、RFC 7519/7515/7518/8725 と OWASP を一次ソースにまとめます。

HTTP ステータスコードの実践的な使い分け - 401 と 403、3xx の違いをちゃんと選ぶ

HTTP ステータスコードの実践的な使い分け - 401 と 403、3xx の違いをちゃんと選ぶ

9

HTTP ステータスコードを、API 設計で迷いやすいポイントを中心に整理します。2xx/3xx/4xx/5xx の意味と使い分け、401 と 403 の違い、301/302/307/308 のメソッド保持、200 でエラーを返すアンチパターン、404 と 410、429 と Retry-After、422 の位置づけまで、RFC 9110 や MDN といった一次ソースをもとにまとめます。