Skip to Content
WebhooksXác thực chữ ký

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: 1729536000

Chuỗi hex sau sha256=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

lib/verify-infraio.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):

  1. Dedup trên X-Delivery trong 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ường event_id.)
  2. 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

  1. Dashboard → Developers → Webhooks → [endpoint] → ⋮ → Rotate Secret.
  2. Một secret mới được sinh và hiển thị đúng một lần. Copy nó trước khi đóng dialog.
  3. 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: 1729536000

Mộ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ứngNguyên nhân khả dĩ
Luôn trả false trong devBody đã được JSON-parse trước HMAC. Đọc raw bytes trước.
Hôm qua chạy, hôm nay failBạ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ớiMộ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 prodMộ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 mismatchVerifier của bạn đang ký chỉ body (scheme pre-2026). Cập nhật để ký timestamp + "." + body.
Header thiếu hoàn toànEndpoint được đăng ký cho môi trường khác. Endpoint test-mode chỉ nhận event environment=test.