Phần lớn lỗi xác minh chữ ký webhook không phải bug crypto. Đó là bug parse. Ai đó parse body trước khi xác minh. Ai đó dùng hex khi nhà cung cấp muốn base64. Ai đó so sánh bằng === khi cần so sánh timing-safe. Hướng dẫn này là bản tóm tắt của mọi vấn đề chữ ký mà chúng tôi từng gỡ lỗi.
Mô hình mà các nhà cung cấp dùng
Mọi nhà cung cấp webhook lớn — Stripe, GitHub, Shopify, Twilio, Slack, Discord — về cơ bản đều làm cùng một việc trong những lớp áo khác nhau:
- Bạn và nhà cung cấp chia sẻ một secret.
- Nhà cung cấp tính một chữ ký trên body của request (đôi khi cộng timestamp, đôi khi cộng URL).
- Chữ ký được gửi trong một header.
- Handler của bạn tính lại chữ ký bằng cùng secret và từ chối request nếu chúng không khớp.
Điều thay đổi giữa các nhà cung cấp: thuật toán (HMAC SHA-256 phổ biến nhất; Discord dùng Ed25519), kiểu mã hóa (hex hay base64), cái gì được ký (body, body+timestamp, URL+body+tham số), và header nào mang chữ ký.
Bốn bug chiếm gần như toàn bộ
1. Parse body trước
Hầu hết framework có một middleware JSON parse body đến thành object. Khoảnh khắc nó chạy, các byte thô biến mất, và mọi chữ ký bạn tính sẽ không khớp — kể cả khi bạn stringify lại object đã parse, khoảng trắng và thứ tự khóa có thể khác.
Cách sửa: đọc body thô trước bất kỳ middleware nào trên route webhook. Trong Express, mount express.raw({ type: 'application/json' }) chỉ trên path webhook. Trong Next.js App Router, đọc await request.text() trực tiếp. Trong Django, dùng @csrf_exempt + request.body trước khi bất kỳ parser DRF nào chạy.
2. Sai kiểu mã hóa
Stripe và GitHub dùng hex. Shopify dùng base64. Cùng một secret tạo ra đầu ra trông khác nhau ở mỗi bên. Chúng tôi đã đề cập trong hướng dẫn Shopify — nó cắn mọi người.
3. Không timing-safe
So sánh chuỗi (===) trả về sớm ở ký tự đầu tiên không khớp. Chênh lệch thời gian giữa "khớp ký tự đầu" và "khớp cả 64 ký tự" làm rò rỉ thông tin về secret. Hãy dùng so sánh timing-safe:
// Node
crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
// Python
hmac.compare_digest(a, b)
// Ruby
ActiveSupport::SecurityUtils.secure_compare(a, b)
Trên thực tế, tấn công này khó thực hiện đối với một endpoint webhook qua internet công cộng, nhưng so sánh an toàn rẻ đến mức không có lý do gì để không dùng.
4. Không xác thực timestamp
Nếu chữ ký chỉ bao phủ body, kẻ tấn công bắt được một request hợp lệ có thể replay nó mãi mãi. Nhà cung cấp giải quyết bằng cách đưa timestamp vào payload chữ ký và một cửa sổ dung sai trong tài liệu (Stripe: 5 phút; GitHub: cũng ngắn). Handler của bạn nên từ chối bất kỳ chữ ký nào có timestamp cũ hơn mức dung sai.
Bỏ qua bước này và bạn đã xây một hệ thống dễ bị replay, dù HMAC có hoạt động hoàn hảo đến đâu.
Trong header thực ra có gì
Header của nhà cung cấp mang nhiều hơn chỉ "chữ ký". Stripe gửi t=1234567890,v1=abcdef...,v0=... — timestamp cộng nhiều phiên bản chữ ký. GitHub gửi sha256=abcdef... có tiền tố. Twilio đưa URL request vào payload ký nhưng chỉ gửi chính chữ ký trong X-Twilio-Signature. Hãy đọc tài liệu của nhà cung cấp bạn tích hợp, rồi triển khai theo đúng định dạng.
Kiểm thử cục bộ giúp gỡ lỗi được
Xác minh chữ ký là loại bug tệ nhất: âm thầm và khó tái hiện trên staging. Kiểm thử cục bộ với một tunnel và bắt request cho bạn đúng payload đã thất bại, đúng header đã đến, và một nút replay để kiểm thử lại bản sửa mà không làm phiền nhà cung cấp.
Khi việc xác minh từ chối điều gì đó, hãy log đủ để gỡ lỗi: độ dài body thô, header bạn đọc, chữ ký bạn tính. Đừng log secret. (Chúng tôi từng thấy người ta log secret. Đừng.)
Một verifier tối thiểu đúng
// Express + Node, generic HMAC SHA-256 hex provider
app.post(
'/webhooks/foo',
express.raw({ type: 'application/json' }),
(req, res) => {
const signature = req.headers['x-foo-signature'];
const timestamp = req.headers['x-foo-timestamp'];
// 1. Reject old timestamps (5 min tolerance)
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
return res.status(400).send('stale');
}
// 2. Compute expected signature
const expected = crypto
.createHmac('sha256', process.env.FOO_SECRET)
.update(`${timestamp}.${req.body}`)
.digest('hex');
// 3. Timing-safe compare
if (!crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signature),
)) {
return res.status(401).send('invalid');
}
// 4. Now safe to parse and process
const event = JSON.parse(req.body);
handleEvent(event);
res.status(200).end();
},
);
Khi xác minh chữ ký vẫn thất bại
Nếu mọi thứ trên đều đúng mà vẫn thất bại, kiểm tra thêm ba thứ: secret có khoảng trắng cuối từ file env, tunnel của bạn đã viết lại một header (đa số không, nhưng đáng xác nhận bằng bắt request), hoặc bạn đang kiểm thử với secret live trong khi dashboard hiển thị secret test. Chúng tôi đã gặp cả ba.
Về các điểm cần lưu ý theo từng nhà cung cấp, xem hướng dẫn Stripe, GitHub và Shopify. Hoặc nếu bạn đang nhìn chằm chằm vào một lỗi 401 ngay lúc này, hãy chuyển tới gỡ lỗi 401 của webhook. Tham gia danh sách chờ PortPreview để có tunnel + bắt request giúp việc này gỡ lỗi được.