Xác thực chữ ký
Mọi delivery webhook bao gồm hai header dùng cùng nhau:
X-Signature: sha256=9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b
X-Timestamp: 1729536000Chuỗi hex sau sha256= là HMAC-SHA256(secret, timestamp + "." + raw_body).
Dấu chấm là một byte literal; timestamp là unix-seconds dưới dạng ASCII.
Vì sao phải xác thực
URL webhook bị lộ. Chúng xuất hiện trong log proxy, screenshot, lịch
sử trình duyệt, ticket support của partner. Không có kiểm tra chữ ký,
bất kỳ ai biết URL của bạn đều có thể POST một event payment.settled
giả và lừa bạn fulfillment các order chưa thanh toán. Xác thực chứng
minh mật mã rằng request đến từ InfraIO.
Bao gồm cả timestamp bên trong payload đã ký cũng cho bạn chống replay: một attacker capture được một delivery không thể gửi lại sau này mà không khiến chữ ký trở nên detectably stale.
Thuật toán
signed_payload = timestamp + "." + raw_body
expected_header = "sha256=" + lowercase_hex( HMAC_SHA256(secret, signed_payload) )
constant_time_compare(expected_header, x_signature_header)Sau đó kiểm tra timestamp gần đây (tolerance điển hình: ±5 phút).
Luôn truyền raw bytes của request body. Framework thường parse
JSON trước khi handler của bạn chạy; phiên bản re-stringify có thể
khác với cái chúng tôi gửi (thứ tự key, whitespace, định dạng số),
và HMAC sẽ không khớp. Trong Next.js App Router dùng
await req.text() trước JSON.parse. Trong Express, mount
express.raw({ type: 'application/json' }) chỉ trên route webhook.
Cài đặt
Node / TS
import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECONDS = 5 * 60;
export function verifyInfraIo({
body,
signature,
timestamp,
secret,
}: {
body: string; // text raw — KHÔNG phải JSON đã parse
signature: string; // giá trị của header X-Signature
timestamp: string; // giá trị của header X-Timestamp (unix seconds)
secret: string; // whsec_…
}): boolean {
const ts = Number.parseInt(timestamp, 10);
if (!Number.isFinite(ts)) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - ts) > TOLERANCE_SECONDS) {
return false; // quá cũ hoặc quá xa trong tương lai
}
const expected = "sha256=" + createHmac("sha256", secret)
.update(`${timestamp}.${body}`)
.digest("hex");
const a = Buffer.from(signature);
const b = Buffer.from(expected);
return a.length === b.length && timingSafeEqual(a, b);
}Chống replay
Timestamp bên trong payload đã ký là lớp phòng vệ đầu tiên — một attacker capture được một delivery không thể gửi lại sau khi cửa sổ tolerance của bạn hết hạn.
Belt-and-braces (khuyến nghị cho event giá trị cao như
payment.settled):
- Dedup trên
X-Deliverytrong một bảng với unique constraint. Replay trong cửa sổ tolerance trở thành no-op — handler của bạn trả 200 mà không làm việc gấp đôi. Đây là cùng idempotency bạn muốn cho retry hợp pháp. (X-Deliveryổn định qua mọi retry của một delivery; payload không có trườngevent_id.) - Dùng tolerance nhỏ nhất mà drift đồng hồ của bạn cho phép. ±5 phút là mặc định khuyến nghị và khớp với những gì hầu hết fleet đã đồng bộ NTP có thể sustain. Chặt hơn vẫn ổn; dưới ±30 giây bạn sẽ bắt đầu reject delivery hợp pháp trên network có NTP upstream chậm.
Rotate một secret
- Dashboard → Developers → Webhooks → [endpoint] → ⋮ → Rotate Secret.
- Một secret mới được sinh và hiển thị đúng một lần. Copy nó trước khi đóng dialog.
- Cập nhật env var và redeploy verifier của bạn trong vòng 24 giờ.
Cửa sổ grace (dual-sign)
Trong 24 giờ sau khi rotate, mọi delivery mang hai chữ ký:
X-Signature: sha256=<hmac(new_secret, ts + "." + body)>
X-Signature-Prev: sha256=<hmac(prev_secret, ts + "." + body)>
X-Timestamp: 1729536000Một verifier chạy secret trước đó match X-Signature-Prev; một
verifier chạy secret mới match X-Signature. Một trong hai header
pass là đủ — handler của bạn có thể chấp nhận delivery trong quá trình
migration mà không cần giữ deploy.
Sau khi cửa sổ grace đóng, chỉ X-Signature được gửi. Secret trước
đó ngừng được chấp nhận và bất kỳ verifier nào vẫn cấu hình với nó
sẽ bắt đầu reject delivery — nên hãy kết thúc rollout trong budget
24 giờ.
Pattern receiver gợi ý
// Chấp nhận một trong hai chữ ký trong cửa sổ grace rotate.
const sig = req.headers["x-signature"] ?? "";
const sigPrev = req.headers["x-signature-prev"] ?? "";
const ok = verify(body, sig, ts, CURRENT_SECRET)
|| (PREV_SECRET && verify(body, sigPrev, ts, PREV_SECRET));Bạn có thể bỏ nhánh X-Signature-Prev ngay khi cửa sổ grace trên
endpoint của bạn đã hết và bạn đã loại bỏ PREV_SECRET khỏi env.
Revoke khẩn cấp
Nếu một secret bị lộ công khai và bạn cần vô hiệu hóa secret trước đó ngay lập tức — tức là bạn không muốn overlap 24 giờ giữ một key đã biết là bad sống — rotate hai lần. Rotate đầu tiên chuyển secret bị lộ vào slot prev; rotate thứ hai đẩy nó ra khỏi slot prev (thay thế bằng key vẫn mới) để giá trị bị lộ không còn được chấp nhận nữa.
Test wiring của bạn
Trong dashboard, mở Developers → Webhooks và bấm Send Test trên endpoint bạn muốn xác minh. Chúng tôi ký và POST một envelope synthetic đến URL đồng bộ, rồi hiển thị HTTP status, latency, và snippet 512 byte của response. Hình dáng payload:
{
"event_id": "<uuid>",
"event_type": "webhook.test.ping",
"created_at": "2026-05-17T12:00:00Z",
"test": true,
"data": {
"merchant_id": "<your-merchant-id>",
"webhook_id": "<endpoint-id>",
"message": "Test ping from the merchant dashboard..."
}
}Test ping dùng cùng scheme ký như delivery production, nên một check xanh từ nút này xác nhận verifier của bạn chấp nhận cả event thật. Test ping bỏ qua pipeline retry RMQ — nếu bạn muốn exercise retry, kích hoạt một event thật qua luồng API liên quan.
Lỗi thường gặp
| Triệu chứng | Nguyên nhân khả dĩ |
|---|---|
| Luôn trả false trong dev | Body đã được JSON-parse trước HMAC. Đọc raw bytes trước. |
| Hôm qua chạy, hôm nay fail | Bạn đã rotate secret nhưng env var trên server này vẫn còn cái cũ. Redeploy với secret mới. |
| Fail với event cũ, hoạt động với event mới | Một delivery được xếp hàng đợi trước khi rotate; chữ ký dùng secret cũ và verifier của bạn không còn chấp nhận. Chờ retry drop nó hoặc replay qua dashboard. |
| Off-by-one trong so sánh timestamp | Đảm bảo bạn so sánh unix-seconds với unix-seconds. Date.now() trong JS là milliseconds — chia cho 1000. |
| Chạy local OK, fail trên prod | Một proxy (Cloudflare, nginx) đang decompress, re-encode, hoặc strip một trailing newline. Inspect bytes mà handler của bạn thấy. |
| Test ping báo 401 / signature mismatch | Verifier của bạn đang ký chỉ body (scheme pre-2026). Cập nhật để ký timestamp + "." + body. |
| Header thiếu hoàn toàn | Endpoint được đăng ký cho môi trường khác. Endpoint test-mode chỉ nhận event environment=test. |