Xác minh chữ ký webhook HMAC-SHA256 và chống replay 300 giây kèm code mẫu

Chúng tôi từng nghe nhiều chủ shop kể cùng một chuyện: khách chuyển khoản lúc nửa đêm, nhưng đến sáng hôm sau họ mới biết vì phải mở app ngân hàng kiểm tra bằng tay. Đơn hàng nằm chờ, khách nhắn tin hỏi dồn dập. Từ những trường hợp đó, đội ngũ bên mình muốn viết bài này để đi sâu vào phần kỹ thuật ít được nhắc tới: làm sao để xác minh chữ ký webhook vừa đảm bảo gói tin đúng là của bên gửi, vừa không bị kẻ xấu gửi lại.

Webhook xác nhận chuyển khoản hoạt động ra sao?

Khi có tiền vào tài khoản, dịch vụ trung gian nhận thông báo từ ngân hàng rồi gửi một yêu cầu HTTP đến địa chỉ máy chủ của bạn. Cơ chế này gọi là webhook, tức là một cuộc gọi chủ động từ bên ngoài vào hệ thống của bạn.

Vấn đề là bất kỳ ai biết địa chỉ webhook đều có thể gửi một gói tin giả báo “đã nhận tiền”. Vì thế, nhà cung cấp thường ký gói tin bằng HMAC-SHA256 với một khoá bí mật chỉ hai bên biết. Máy chủ của bạn tính lại chữ ký và so khớp. Nếu khớp, gói tin đến từ nguồn đáng tin.

Tuy vậy, chữ ký chỉ chứng minh gói tin là thật, không chứng minh nó còn mới. Một gói tin hợp lệ có thể bị ghi lại rồi gửi lại vài giờ sau, và chữ ký vẫn đúng. Đó chính là tấn công replay. Muốn chặn kiểu này, bạn cần thêm bước kiểm tra thời gian.

Quy trình xác minh chữ ký HMAC-SHA256

Quy trình thường gồm bốn bước. Trước hết, bạn lấy nguyên văn phần thân yêu cầu (raw body) trước khi parse JSON, vì chỉ một khoảng trắng khác đi là chữ ký sẽ không khớp. Tiếp theo, tính HMAC-SHA256 của chuỗi đó với khoá bí mật. Sau đó, so sánh kết quả với giá trị trong header bằng hàm so sánh an toàn theo thời gian, để kẻ tấn công không đoán dần chữ ký qua việc đo độ trễ phản hồi. Cuối cùng, chỉ xử lý khi cả bốn bước đều đạt.

Lỗi phổ biến là dùng phép so sánh chuỗi thông thường. Các toán tử như === dừng ngay khi gặp ký tự khác đầu tiên, và chính khoảng dừng đó tạo kẽ hở để dò chữ ký. Hãy dùng hàm constant-time mà ngôn ngữ bạn đang dùng cung cấp.

Chống replay trong 300 giây

Hầu hết nhà cung cấp gửi kèm một dấu thời gian trong header. Bạn đọc giá trị này, so với đồng hồ máy chủ, rồi từ chối mọi gói tin lệch quá 300 giây. Con số 300 giây được dùng phổ biến vì đủ rộng để bù độ trễ mạng, nhưng cũng đủ hẹp để một gói tin bị chặn mất giá trị chỉ sau vài phút.

Máy chủ phải đồng bộ giờ, thường qua NTP. Nếu đồng hồ lệch vài phút, bạn sẽ từ chối nhầm những giao dịch thật. Lỗi này khá khó phát hiện khi mới triển khai, vì hệ thống vẫn chạy bình thường nhưng thỉnh thoảng có đơn bị kẹt.

Ngoài kiểm tra thời gian, nên lưu lại mã giao dịch đã xử lý trong một khoảng ngắn, ví dụ dùng Redis với TTL 300 giây. Nếu cùng một mã xuất hiện lần thứ hai, hãy bỏ qua và trả về thành công để nhà cung cấp không gửi lại liên tục.

Code mẫu Node.js

Đoạn code dưới đây dùng Express và module crypto có sẵn của Node.js. Chúng tôi giả định header chữ ký là x-signature và header thời gian là x-timestamp.

const crypto = require('crypto');
const express = require('express');
const app = express();
const SECRET = process.env.WEBHOOK_SECRET;
const TOLERANCE = 300;

app.post('/webhook/bank', express.raw({ type: '*/*' }), (req, res) => {
  const ts = parseInt(req.headers['x-timestamp'], 10);
  const now = Math.floor(Date.now() / 1000);
  if (!ts || Math.abs(now - ts) > TOLERANCE) return res.status(400).end('stale');

  const expected = crypto.createHmac('sha256', SECRET)
    .update(`${ts}.${req.body.toString()}`).digest('hex');
  const given = req.headers['x-signature'] || '';

  const ok = given.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected));
  if (!ok) return res.status(401).end('bad signature');

  const event = JSON.parse(req.body.toString());
  // xử lý cập nhật đơn hàng và gửi email xác nhận tại đây
  res.status(200).end('ok');
});

Chuỗi được ký ở đây là timestamp nối với thân yêu cầu bằng dấu chấm. Hãy đối chiếu đúng định dạng trong tài liệu của nhà cung cấp, vì mỗi bên có thể khác nhau ở thứ tự nối hoặc ký tự phân cách.

Code mẫu PHP

Phiên bản PHP theo cùng logic, dùng hash_hmac và hash_equals.

<?php
$secret = getenv('WEBHOOK_SECRET');
$tolerance = 300;
$body = file_get_contents('php://input');
$ts = (int) ($_SERVER['HTTP_X_TIMESTAMP'] ?? 0);
$given = $_SERVER['HTTP_X_SIGNATURE'] ?? '';

if (!$ts || abs(time() - $ts) > $tolerance) {
  http_response_code(400); exit('stale');
}

$expected = hash_hmac('sha256', $ts . '.' . $body, $secret);
if (!hash_equals($expected, $given)) {
  http_response_code(401); exit('bad signature');
}

$event = json_decode($body, true);
// xử lý cập nhật đơn hàng và gửi email xác nhận tại đây
http_response_code(200); echo 'ok';

Tách riêng module xác minh để dễ bảo trì

Theo kinh nghiệm của đội ngũ, nên gom toàn bộ phần xác minh vào một module riêng, gồm kiểm tra chữ ký, kiểm tra thời gian và chống trùng lặp. Phần nghiệp vụ như cập nhật đơn hàng chỉ nhận dữ liệu đã được xác thực. Nhờ vậy, khi đổi nhà cung cấp, bạn chỉ sửa module này, còn logic đơn hàng và email giữ nguyên.

Những lỗi thường gặp khi triển khai

Lỗi đầu tiên là parse JSON trước rồi mới tính chữ ký, khiến hai bên tính trên hai chuỗi khác nhau. Lỗi thứ hai là quên kiểm tra thời gian, nên tấn công replay vẫn lọt. Lỗi thứ ba là để khoá bí mật nằm thẳng trong mã nguồn thay vì biến môi trường. Lỗi thứ tư là trả về thông báo quá chi tiết, khiến kẻ tấn công biết mình sai ở bước nào.

Kiểm thử trước khi đưa lên môi trường thật

Nên gửi thử bốn trường hợp: chữ ký đúng, chữ ký sai, timestamp cũ hơn 300 giây, và gửi lại cùng một gói hai lần. Cả bốn phải cho kết quả đúng kỳ vọng. Nếu nhà cung cấp có môi trường sandbox, hãy dùng nó để kiểm thử trước khi xử lý tiền thật.

Chọn dịch vụ trung gian có hỗ trợ chữ ký tốt

Khi chọn dịch vụ xác nhận chuyển khoản, hãy kiểm tra tài liệu có mô tả rõ cách ký webhook và header thời gian không. Tài liệu rõ ràng thường là dấu hiệu của nhà cung cấp làm bài bản. Bạn cũng nên so sánh hạn mức miễn phí và chi phí khi lượng giao dịch tăng. Ví dụ, dịch vụ xác nhận chuyển khoản qua API ngân hàng của MONA Pay hiện có 500 giao dịch miễn phí mỗi tháng, cùng các gói 99.000đ, 299.000đ, 799.000đ và 1,99 triệu đồng, không thu phần trăm trên giao dịch. Mức giá này tính đến tháng 11/2026, bạn nên đối chiếu bảng giá chính thức trước khi quyết định.

Gợi ý đọc thêm

Nếu đang cân nhắc hạ tầng cho cả dự án, bạn có thể tham khảo những điều cần biết khi chọn nhà cung cấp dịch vụ cho website doanh nghiệp nhỏ để có thêm tiêu chí so sánh. Bạn cũng có thể xem danh sách nhà cung cấp dịch vụ uy tín để đối chiếu theo quy mô của mình.

Câu hỏi thường gặp

Có cần kiểm tra timestamp nếu đã kiểm tra chữ ký không?

Có. Chữ ký chỉ xác nhận gói tin là thật, không xác nhận gói tin còn mới. Thiếu kiểm tra thời gian, một gói tin hợp lệ bị ghi lại vẫn có thể được gửi lại và xử lý thêm lần nữa.

Vì sao phải dùng raw body để tính chữ ký?

Khi parse JSON rồi serialize lại, khoảng trắng, thứ tự khoá hoặc cách mã hoá số có thể thay đổi. Khi đó, chữ ký tính trên bản đã biến đổi sẽ không khớp với bản nhà cung cấp đã ký.

Nên lưu mã giao dịch để chống trùng trong bao lâu?

Khoảng 300 giây là đủ nếu bạn đã kiểm tra timestamp. Muốn an toàn hơn, bạn có thể lưu lâu hơn, nhưng cần cân nhắc dung lượng và chi phí lưu trữ.

Trả lời

Email của bạn sẽ không được hiển thị công khai. Các trường bắt buộc được đánh dấu *

Loading...