Webhook解説:署名付きペイロードとリトライ
サードパーティの決済プロバイダーを統合し、支払いが成功したときにWebhookでアプリに通知すると約束されているとします。しかし、そのリクエストが本当にプロバイダーから送られたものであり、攻撃者からではないとどうやって確認しますか?また、Webhookが発火したときにサーバーが一時的にダウンしていたらどうなるでしょうか?適切な保護措置がなければ、偽のイベントを処理したり、重要な更新を失ったりするリスクがあります。この記事では、署名付きペイロードでWebhookを保護し、堅牢なリトライメカニズムを実装する方法を説明します。
Webhookとは何か?
Webhookはユーザー定義のHTTPコールバックです。ソースシステムでイベントが発生すると(例:支払いが完了した)、指定したURLにHTTP POSTリクエストを送信します。更新を繰り返し問い合わせるポーリングとは異なり、Webhookはほぼリアルタイムでデータをプッシュします。CI/CDトリガー、チャットメッセージ、支払いステータスの変更など、イベント通知に広く使用されています。
なぜ署名でWebhookを保護するのか?
Webhookエンドポイントは公開されているため、誰でもPOSTリクエストを送信できます。検証がなければ、攻撃者がイベントを偽造し、注文を支払い済みとしてマークするなどの不正なアクションを引き起こす可能性があります。共有シークレットでペイロードに署名することで、真正性と完全性を検証できます。
署名付きペイロードの仕組み
送信者と受信者は秘密鍵を共有します。各Webhookについて、送信者はリクエスト本文に対して暗号署名(通常はHMAC)を計算し、ヘッダーに含めます。受信者は同じ秘密鍵を使用して署名を再計算し、比較します。一致すれば、リクエストは本物です。
適切なアルゴリズムの選択
HMAC-SHA256がデファクトスタンダードです。高速で安全であり、すべての主要言語で利用可能です。弱いと見なされるMD5やSHA1は避けてください。
ステップバイステップ:署名付きペイロードの検証
- 署名を抽出する(例:
X-Signatureヘッダーから)。 - 生のリクエスト本文を文字列またはバイト配列として取得する。最初に解析しないでください。変換すると署名が変わります。
- HMACを計算する(秘密鍵と生の本文を使用)。
- 比較する(計算した署名と受信した署名を、タイミング攻撃を防ぐために定数時間比較で)。
Expressを使用したNode.jsの例:
const crypto = require('crypto');
const express = require('express');
const app = express();
// Use raw body parser for webhook route
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-signature'];
const secret = process.env.WEBHOOK_SECRET;
const hmac = crypto.createHmac('sha256', secret);
const digest = hmac.update(req.body).digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(digest))) {
return res.status(401).send('Invalid signature');
}
// Process the event
const event = JSON.parse(req.body);
// ... handle event
res.status(200).send('OK');
});
よくある落とし穴:検証前にJSONボディパーサーを使用すること。Expressのexpress.json()は本文を解析するため、生のバイトが失われます。Webhookルートには常にexpress.raw()を使用するか、生の本文を別途キャプチャしてください。
リトライ戦略の設計
署名があってもWebhookは失敗する可能性があります。サーバーがダウンしているか、リクエストがタイムアウトするかもしれません。信頼性の高い送信者は失敗した配信をリトライします。受信者としても、リトライを適切に処理する必要があります。
送信者側のリトライ
送信者を構築する場合は、ジッター付きの指数バックオフを実装してください。たとえば、1分後、5分後、30分後にリトライし、最大試行回数(例:5回)まで行います。ペイロードに一意のイベントIDを含めて、受信者が重複排除できるようにします。
受信者側の冪等性
リトライにより重複配信が発生する可能性があるため、エンドポイントは冪等でなければなりません。イベントIDを使用して処理済みイベントを追跡します。一意制約付きでデータベースに保存するか、TTL付きのRedisなどのキャッシュを使用します。イベントIDが既に確認されている場合は、再処理せずに直ちに200 OKを返します。
データベースの一意インデックスを使用した例:
async function handleWebhook(event) {
try {
await db.query('INSERT INTO processed_events (event_id) VALUES ($1)', [event.id]);
} catch (err) {
if (err.code === '23505') { // unique violation
return; // already processed
}
throw err;
}
// process event
}
Webhookエンドポイントのベストプラクティス
- 迅速に応答する:数秒以内に2xxステータスを返します。重い処理はバックグラウンドジョブにオフロードします。
- タイムスタンプを検証する:リプレイ攻撃を防ぐために、署名にタイムスタンプを含めます。数分以上古いリクエストは拒否します。
- HTTPSを使用する:転送中のペイロードを保護するために常にTLSを使用します。
- すべてをログに記録する:デバッグのために、ヘッダーと本文を含む受信Webhookをログに記録します。
- 失敗を監視する:繰り返しの失敗や署名の不一致に対してアラートを設定します。
比較:署名付き vs 署名なしWebhook
| 側面 | 署名付きWebhook | 署名なしWebhook |
|---|---|---|
| セキュリティ | 高 – 送信者と完全性を検証 | 低 – 誰でもリクエストを偽造可能 |
| 実装の複雑さ | 中 – HMACとシークレット管理が必要 | 低 – POSTを受け入れるだけ |
| ユースケース | 支払い、機密データ | 重要でない通知 |
FAQ
PythonでWebhook署名を検証するには?
SHA256でhmacモジュールを使用します。計算したダイジェストをヘッダーと比較するには、定数時間比較のためにhmac.compare_digestを使用します。
Webhookエンドポイントが重複イベントを受信した場合はどうすればよいですか?
イベントIDを処理済みIDのストアと照合します。重複している場合は、200 OKを返して処理をスキップします。これにより冪等性が確保されます。
Webhook送信者は何回リトライすべきですか?
固定の数はありませんが、指数バックオフ(例:1分、5分、30分)で3〜5回のリトライが一般的です。その後、配信を失敗としてマークし、アラートを出します。
Webhookエンドポイントをテストする準備はできましたか?開発中にペイロードを整形して検証するには、JSON Formatterをご利用ください。