Webhook 詳解:簽名載荷與重試機制
你剛剛整合了一個第三方支付提供商。文件上寫著:「支付成功時我們會發送一個 Webhook。」聽起來很簡單,對吧?但接著你意識到:你怎麼知道這個請求真的來自他們?如果他們發送時你的伺服器掛了怎麼辦?如果他們同一個事件發送了兩次呢?
Webhook 很強大,但需要小心處理。本指南涵蓋了基本要點:對載荷進行簽名以驗證真實性、實作重試以處理失敗,以及確保冪等性以避免重複處理。
什麼是 Webhook?
Webhook 是一種 HTTP 回呼:與其讓你的應用程式輪詢 API 來獲取變更,不如由提供商在事件發生時向你指定的 URL 發送 HTTP POST 請求。這使得整合高效且近乎即時。
常見的例子包括支付通知(Stripe、PayPal)、CI/CD 觸發器(GitHub、GitLab)以及訊息平台(Slack、Discord)。
為什麼要用簽名保護 Webhook?
你的 Webhook 端點是公開可存取的。任何人都可以向它發送 POST 請求。如果沒有驗證,攻擊者可以偽造事件——例如,將未支付的訂單標記為已支付。
使用共享密鑰對載荷進行簽名,可以讓你驗證請求確實來自預期的發送方,並且載荷未被篡改。
HMAC 簽名如何運作
提供商與你共享一個密鑰。對於每個 Webhook,提供商使用該密鑰計算載荷的 HMAC(基於雜湊的訊息驗證碼),並將簽名包含在標頭中(例如 X-Signature)。你在自己這邊重新計算 HMAC 並進行比較。
HMAC 確保完整性和真實性,因為只有擁有密鑰的人才能產生有效的簽名。
實作簽名載荷
讓我們逐步了解 Node.js(Express)和 Python(Flask)中的典型實作。這些原則適用於任何語言。
步驟 1:取得並儲存密鑰
當你註冊 Webhook 時,提供商會給你一個密鑰。請安全地儲存它——使用環境變數或密鑰管理器——切勿寫在程式碼中。
步驟 2:捕獲原始請求主體
簽名驗證需要確切的原始請求主體位元組。如果你的框架在驗證之前解析了 JSON,重新序列化可能會改變位元組並導致驗證失敗。
在 Express 中,對 Webhook 路由使用 express.raw({ type: 'application/json' })。在 Flask 中,在解析之前存取 request.get_data()。
步驟 3:計算並比較簽名
使用恆定時間比較函數以防止計時攻擊。
// Node.js (Express)
const crypto = require('crypto');
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-signature'];
const secret = process.env.WEBHOOK_SECRET;
const expected = crypto.createHmac('sha256', secret).update(req.body).digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
return res.status(401).send('Invalid signature');
}
// Process event
const event = JSON.parse(req.body);
// ... handle event
res.status(200).send('OK');
});
在 Python 中,使用 hmac.compare_digest:
# Python (Flask)
import hmac, hashlib, os
from flask import request, abort
@app.route('/webhook', methods=['POST'])
def webhook():
signature = request.headers.get('X-Signature')
secret = os.environ['WEBHOOK_SECRET'].encode()
expected = hmac.new(secret, request.get_data(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(signature, expected):
abort(401)
event = request.get_json()
# ... handle event
return 'OK', 200
步驟 4:處理時間戳(可選但建議)
一些提供商在簽名中包含時間戳以防止重放攻擊。例如,Stripe 的方案是對 timestamp.payload 進行簽名。你應該拒絕時間戳過於久遠的請求(例如超過 5 分鐘)。
實作重試
網路會故障。你的伺服器可能宕機,或者請求可能超時。可靠的 Webhook 系統會重試失敗的傳遞。
在提供商端
如果你正在建立 Webhook 發送器,請實作帶有指數退避的重試。常見模式:1 分鐘後重試,然後 5 分鐘,然後 30 分鐘,依此類推,直到最大嘗試次數(例如 5 次)。在載荷中包含唯一的事件 ID,以便接收方可以去重。
在接收方端
作為接收方,你應該:
- 快速回應(幾秒內)並返回 2xx 狀態以確認收到。將繁重的處理非同步進行。
- 對於需要重試的錯誤(例如 500)返回非 2xx。但要小心:如果你返回 4xx,提供商可能不會重試。
- 使你的端點具有冪等性,這樣重複傳遞不會造成問題。
冪等性:處理重複事件
重試可能導致重複事件。使用事件 ID(大多數提供商都會包含)來追蹤已處理的事件。在處理之前,檢查該 ID 是否已見過;如果是,則跳過。
將已處理的 ID 儲存在具有 TTL 的資料庫中,或在業務邏輯中使用冪等鍵。
在本機測試 Webhook
在開發過程中,你的本機伺服器不是公開可存取的。像 ngrok 或 localtunnel 這樣的工具會建立一個公共 URL,隧道連接到你的機器。或者,使用一個捕獲請求並允許你重放的 Webhook 測試服務。
常見陷阱與最佳實踐
- 在驗證之前不要信任載荷。 始終先驗證簽名。
- 使用 HTTPS。 Webhook 攜帶敏感資料;在傳輸中加密。
- 記錄一切。 記錄原始請求(標頭和主體)以供除錯,但需遮蔽敏感資訊。
- 監控失敗。 為重複的 Webhook 失敗設定警報。
- 為事件設定版本。 在載荷中包含版本,以便在不破壞接收方的情況下演進。
比較:簽名與未簽名 Webhook
| 方面 | 簽名 | 未簽名 |
|---|---|---|
| 安全性 | 高:驗證來源和完整性 | 低:任何人都可以偽造 |
| 實作工作量 | 中等:計算並比較 HMAC | 極少 |
| 推薦用於 | 所有生產系統 | 僅限內部、受信任的網路 |
常見問題
如何驗證 Webhook 簽名?
使用共享密鑰計算原始請求主體的 HMAC,然後使用恆定時間函數將其與簽名標頭進行比較。始終使用原始主體,而不是解析後的 JSON。
我的 Webhook 端點應該返回什麼?
快速返回 2xx 狀態碼以確認收到。如果處理失敗,返回 5xx 以觸發重試,但請確保你的端點具有冪等性以處理重複。
Webhook 會被重試多少次?
這取決於提供商。大多數會在數小時或數天內使用指數退避重試幾次。請查看提供商的文件以了解具體細節。
在除錯 Webhook 傳遞時,通常需要檢查伺服器日誌。如果你使用的是 Nginx,Nginx 日誌分析器 可以幫助你解析和分析存取日誌,以發現失敗的請求或不尋常的模式。