Xác minh webhook thanh toán crypto (HMAC-SHA256) mà không tự bắn vào chân
Một endpoint webhook không xác minh chữ ký là một endpoint phát hàng miễn phí
Webhook thanh toán là một request POST từ Internet nói rằng "đơn ORDER-1001 đã được thanh toán". Nếu handler của bạn tin nó mà không kiểm tra chữ ký, thì bất kỳ ai biết được URL — từ một dòng log rò rỉ, một extension trình duyệt, một commit cũ, hay đơn giản là đoán ra — đều có thể tự giao hàng cho mình miễn phí. Không có rate limit nào chữa được chuyện này, và không có sự "khó đoán" nào làm nó chấp nhận được.
Xác minh chữ ký là toàn bộ tuyến phòng thủ, và nó dài khoảng hai mươi dòng. Bài này nói về cách chữ ký được tạo ra, năm lỗi khiến việc xác minh trông có vẻ đúng nhưng thực chất không làm gì cả, và các handler chạy được mà bạn có thể dán thẳng vào dự án.
Chữ ký được tạo ra thế nào
Mỗi lần giao webhook của NextCryptoPay là một POST kèm các header sau:
| Header | Ý nghĩa |
|---|---|
x-nextcryptopay-event | Tên sự kiện: invoice.confirming, invoice.paid, invoice.expired, invoice.updated |
x-nextcryptopay-delivery | Delivery id ổn định. Lần giao lại của cùng sự kiện giữ nguyên id — dùng nó để khử trùng lặp |
x-nextcryptopay-timestamp | Unix seconds tại thời điểm ký |
x-nextcryptopay-signature | HMAC-SHA256 dạng hex của chuỗi {timestamp}.{rawBody}, khoá là webhook secret của shop |
Chuỗi được ký gồm timestamp, một dấu chấm, rồi raw body đúng như nó đến trên đường truyền. Việc đưa timestamp vào bên trong phần được ký chính là thứ làm cho chống replay khả thi: kẻ tấn công bắt được một lần giao hợp lệ không thể thay đổi độ "cũ" của nó mà không làm hỏng chữ ký.
Bộ header cũ. Bốn header y hệt cũng được gửi với tiền tố cũ
x-cryptopay-*và giá trị giống hệt, để các tích hợp viết trước khi đổi tên vẫn chạy. Chúng sẽ bị gỡ vào 31/03/2027. Mã mới nên đọcx-nextcryptopay-*và chỉ coi tên cũ là phương án dự phòng trong giai đoạn chuyển đổi.
Năm lỗi kinh điển
1. Ký trên body đã parse thay vì raw body
Đây là lỗi phổ biến nhất, bỏ xa các lỗi còn lại, và nó khó chịu vì thường chạy tốt lúc test rồi hỏng trên production. JSON.parse rồi JSON.stringify không tái tạo lại đúng chuỗi byte ban đầu: thứ tự khoá có thể đổi, khoảng trắng biến mất, số bị chuẩn hoá lại, ký tự ngoài ASCII có thể được escape khác đi. HMAC tính trên byte, nên chỉ cần lệch một byte là ra một digest khác.
Trong Express, điều này nghĩa là express.json() không được chạm vào route webhook. Với các framework trao cho bạn một Request, hãy gọi await req.text() trước khi có gì đó parse nó.
2. So sánh chuỗi bằng ===
So sánh chuỗi ngây thơ dừng lại ngay khi gặp ký tự đầu tiên khác nhau. Thời gian nó chạy vì thế rò rỉ thông tin về số ký tự đầu đã đúng, và kẻ tấn công gửi được nhiều request có thể dò ra chữ ký đúng từng ký tự một. Hãy dùng so sánh thời gian hằng. Trong Node đó là crypto.timingSafeEqual, hàm này ném lỗi nếu hai buffer khác độ dài — nên hãy kiểm tra độ dài trước bằng một câu if thường, rồi mới gọi nó.
3. Không kiểm tra timestamp
Không có bước kiểm tra độ tươi, một lần giao hợp lệ bắt được một lần sẽ hợp lệ mãi mãi. Bất kỳ ai phát lại được nó đều kích hoạt lại được mọi thứ handler của bạn làm. Hãy từ chối mọi thứ cũ hơn vài phút; năm phút là mặc định hợp lý, đủ rộng để chịu được sai lệch đồng hồ vừa phải giữa máy chủ của bạn và của chúng tôi.
4. Không khử trùng lặp
Retry là chuyện có thật. Sự kiện cũng có thể được gửi lại thủ công từ dashboard. Nếu khâu xử lý đơn của bạn không idempotent, cùng một đơn sẽ được giao hai lần. Id ở x-nextcryptopay-delivery giữ nguyên qua các lần giao lại của cùng một sự kiện: hãy lưu các id đã xử lý và trả 200 ngay cho bất cứ id nào đã thấy. Một unique index trên delivery id trong cơ sở dữ liệu là bảo đảm mạnh hơn một Set trong bộ nhớ, vì nó sống sót qua restart và hoạt động khi bạn chạy nhiều instance.
5. Trả 2xx trước khi công việc thực sự an toàn
Mã 2xx báo cho chúng tôi rằng lần giao đã thành công và chúng tôi dừng retry. Nếu handler của bạn trả 200 rồi mới sập lúc ghi database, sự kiện đó mất hẳn. Hình dạng đúng là: xác minh, ghi sự kiện xuống nơi bền vững (một dòng trong bảng hàng đợi là đủ), trả 2xx, rồi mới làm phần việc nặng một cách bất đồng bộ. Bạn có mười lăm giây trước khi request timeout; một lần giao thất bại hoặc chậm sẽ được thử lại tối đa sáu lần với backoff 1, 5, 30, 120, 360 và 1440 phút.
Một handler Node đúng chuẩn
import crypto from "node:crypto";
const MAX_AGE_SECONDS = 300;
function verify(rawBody, headers, secret) {
const ts = headers["x-nextcryptopay-timestamp"] ?? headers["x-cryptopay-timestamp"];
const sig = headers["x-nextcryptopay-signature"] ?? headers["x-cryptopay-signature"];
if (!ts || !sig) return false;
// 1) Chống replay.
if (Math.abs(Date.now() / 1000 - Number(ts)) > MAX_AGE_SECONDS) return false;
// 2) HMAC trên "timestamp.rawBody", so sánh thời gian hằng.
const expected = crypto
.createHmac("sha256", secret)
.update(`${ts}.${rawBody}`)
.digest("hex");
if (sig.length !== expected.length) return false;
return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
}
// express.raw, KHÔNG phải express.json — chữ ký tính trên byte thô.
app.post("/webhooks/nextcryptopay", express.raw({ type: "application/json" }), async (req, res) => {
const raw = req.body.toString("utf8");
if (!verify(raw, req.headers, process.env.NEXTCRYPTOPAY_WEBHOOK_SECRET)) {
return res.status(401).end();
}
const deliveryId = req.headers["x-nextcryptopay-delivery"];
if (await alreadyProcessed(deliveryId)) return res.status(200).end();
const { event, invoice } = JSON.parse(raw);
await recordDelivery(deliveryId, event, invoice); // ghi bền vững, rồi mới trả lời
res.status(200).end();
if (event === "invoice.paid") await fulfillOrder(invoice.orderId); // idempotent
});
Cũng việc đó bằng PHP
<?php
$raw = file_get_contents('php://input');
$h = array_change_key_case(getallheaders(), CASE_LOWER);
$ts = $h['x-nextcryptopay-timestamp'] ?? $h['x-cryptopay-timestamp'] ?? '';
$sig = $h['x-nextcryptopay-signature'] ?? $h['x-cryptopay-signature'] ?? '';
if ($ts === '' || $sig === '' || abs(time() - (int)$ts) > 300) {
http_response_code(401);
exit;
}
$expected = hash_hmac('sha256', $ts . '.' . $raw, getenv('NEXTCRYPTOPAY_WEBHOOK_SECRET'));
if (!hash_equals($expected, $sig)) { // hash_equals chạy thời gian hằng
http_response_code(401);
exit;
}
$payload = json_decode($raw, true);
if (($payload['event'] ?? '') === 'invoice.paid') {
fulfill_order($payload['invoice']['orderId']); // bắt buộc idempotent
}
http_response_code(200);
Và bằng Python
import hmac, hashlib, time, json
def verify(raw: bytes, headers, secret: str) -> bool:
ts = headers.get("x-nextcryptopay-timestamp") or headers.get("x-cryptopay-timestamp")
sig = headers.get("x-nextcryptopay-signature") or headers.get("x-cryptopay-signature")
if not ts or not sig:
return False
if abs(time.time() - int(ts)) > 300:
return False
expected = hmac.new(
secret.encode(),
f"{ts}.".encode() + raw, # byte thô, không bao giờ là dict đã serialise lại
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, sig)
Payload trông như thế nào
{
"event": "invoice.paid",
"invoice": {
"id": "inv_abc123",
"orderId": "ORDER-1001",
"status": "PAID",
"chain": "tron",
"asset": "USDT",
"amount": "99.50",
"paidAmount": "99.50",
"payAddress": "TJ9x…"
}
}
Chỉ giao hàng khi status: "PAID". CONFIRMING nghĩa là giao dịch đã xuất hiện trên chain nhưng chưa đạt ngưỡng xác nhận của mạng đó, và một giao dịch đã xuất hiện chưa phải một giao dịch chung cuộc.
Ba điều nữa đáng biết
Redirect không được đi theo. Nếu URL webhook của bạn trả về 301 hoặc 302, lần giao đó bị coi là thất bại. Hãy trỏ thẳng vào URL cuối cùng, và để ý những framework tự chuyển HTTP sang HTTPS hoặc tự thêm/bớt dấu gạch chéo cuối.
URL trỏ vào mạng nội bộ bị từ chối. Một webhook URL phân giải về loopback hoặc dải mạng riêng bị từ chối ngay lúc tạo với UNSAFE_URL, và request đi ra còn ghim đúng địa chỉ đã phân giải để chống DNS rebinding. Nếu bạn test cục bộ, hãy dùng một tunnel có hostname công khai.
Xoay secret một cách có chủ đích. Trong lúc xoay, hãy chấp nhận cả secret cũ lẫn mới trong một khoảng ngắn rồi bỏ cái cũ. Việc xác minh với hai secret ứng viên vẫn là thời gian hằng nếu bạn so sánh cả hai bằng timingSafeEqual thay vì thoát sớm ngay khi khớp cái đầu tiên.
Đọc thêm
Toàn bộ bề mặt API, gồm cả việc tạo hoá đơn và luồng trạng thái trực tiếp, nằm ở trang tài liệu, kèm bản máy đọc được tại /docs-agent.md dành cho AI coding agent. Muốn dựng một tích hợp đầu tiên từ đầu tới cuối, xem hướng dẫn nhận USDT trên TRC-20; còn lý do vì sao toàn bộ chuyện này không hề đòi bạn giao ra khoá riêng thì nằm ở bài non-custodial và custodial.