Bảo mật webhook: chữ ký HMAC-SHA256 và chống replay
Video hướng dẫn
Xem transcript video (9 phần)
Vì sao phải xác thực
URL webhook là địa chỉ công khai. Nếu endpoint tin mọi JSON gửi tới, một gói giả cũng có thể làm đơn chuyển sang đã thanh toán. Trong video này, mình cấu hình HMAC_SHA256, tính lại X-Mona-Signature, chặn phát lại bằng timestamp và chỉ ra cách lấy raw body trong từng framework. Bạn sẽ có một trình tự kiểm tra rõ ràng trước khi chạm vào dữ liệu đơn hàng.
Ba kiểu xác thực
MONA Pay có ba kiểu xác thực. HMAC_SHA256 ký từng gói bằng mã bí mật dùng chung và là lựa chọn được khuyên dùng. API_KEY gửi Secret nguyên văn trong một header. NONE không có header xác thực và chỉ phù hợp môi trường thử nội bộ. Với HMAC, Secret không đi trong request; phía gửi và phía nhận cùng dùng Secret để tính chữ ký từ chính nội dung gói.
MONA Pay tạo chữ ký
Cách ký gồm bốn phần. Lấy timestamp là Unix giây lúc gửi. Lấy raw_body đúng từng byte của body. Ghép timestamp, một dấu chấm và raw body. Tính HMAC-SHA256 bằng Secret, xuất hex chữ thường rồi thêm tiền tố sha256=. Request mang hai header: X-Mona-Timestamp và X-Mona-Signature. Secret nên là chuỗi ngẫu nhiên ít nhất ba mươi hai ký tự, mỗi cấu hình dùng một chuỗi riêng.
Kiểm HMAC và timestamp
Ở endpoint, đọc raw body trước khi parse JSON. Đọc hai header, rồi kiểm chênh lệch giữa giờ hiện tại và timestamp không quá ba trăm giây. Quá cửa sổ năm phút thì trả bốn trăm và bỏ gói để chống phát lại. Sau đó tính lại chữ ký theo đúng công thức. So chữ ký bằng hash_equals trong PHP hoặc crypto.timingSafeEqual trong Node. Không khớp thì trả bốn trăm lẻ một.
Lấy raw body đúng cách
Raw body là chỗ nhiều dự án vấp nhất. PHP thuần dùng file_get_contents('php://input'); Laravel dùng $request->getContent(). Express đặt express.raw ngay tại route webhook rồi đổi body sang chuỗi UTF tám. Next.js App Router dùng await request.text() thay vì request.json(). Django đọc request.body; FastAPI dùng await request.body(). Parse rồi mã hóa lại có thể đổi khoảng trắng, thứ tự khóa và làm chữ ký sai.
Xử lý sau khi hợp lệ
Khi chữ ký hợp lệ, trả HTTP hai trăm ngay rồi mới parse dữ liệu và xử lý đơn ở phía sau. Cột transaction_code cần ràng buộc UNIQUE để cùng một gói không chạy hai lần. Nếu giá trị giao dịch lớn, tài liệu còn khuyên xác minh lại bằng API đối soát trước khi giao hàng. Mã bí mật phải nằm trong biến môi trường, không ghi cứng trong code và không đưa lên kho mã nguồn.
API key và NONE
Nếu hệ thống không tính được HMAC, bạn có thể dùng API_KEY. Đặt tên header qua api_key_name, mặc định là X-Webhook-Secret, rồi so giá trị bằng hàm thời gian cố định. Cách này đơn giản nhưng Secret đi theo mọi request và không chống phát lại. Với NONE, ít nhất chỉ cho phép IP 103.168.55.14 và đối soát lại giao dịch trước khi giao hàng.
Sửa lỗi chữ ký
Nếu chữ ký luôn sai, kiểm tra middleware có parse rồi tạo lại JSON không. Nếu lệnh cURL đúng nhưng gói thật sai, đừng tự dựng lại JSON có khoảng trắng; hãy ký chuỗi nhận được. Báo “timestamp qua han” thường do giờ máy chủ lệch hơn năm phút, cần bật NTP. Với API_KEY không thấy header, chỉ dùng chữ và dấu gạch ngang trong tên, rồi kiểm toàn bộ header máy chủ nhận.
Mở docs và cấu hình
Công thức chữ ký, mã PHP, Node và danh sách kiểm tra đầy đủ nằm tại monapay.vn/docs/webhooks/bao-mat. Bạn đăng ký tại my.monapay.vn, tạo một webhook HMAC riêng cho môi trường thử và lưu Secret đúng cách trước khi chạy thật. Nếu cần đối chiếu header hoặc chữ ký, gọi tổng đài một chín không không, sáu ba sáu, sáu bốn tám để đội MONA hỗ trợ.
Webhook MONA Pay có 3 kiểu xác thực: HMAC_SHA256 (ký từng gói bằng secret chung, khuyên dùng), API_KEY (gửi secret trong một header) và NONE (không xác thực). Với HMAC, mỗi gói kèm header X-Mona-Timestamp và X-Mona-Signature; bạn tính lại chữ ký từ raw body, khớp thì nhận, và từ chối gói có timestamp lệch quá 5 phút.
Vì sao phải xác thực
URL webhook là địa chỉ công khai. Ai đoán được địa chỉ đó đều gửi được một gói JSON giả “có 50.000.000đ vào tài khoản”. Nếu endpoint tin ngay, đơn hàng được đánh dấu đã trả dù không có đồng nào. Xác thực giúp endpoint chỉ tin gói do MONA Pay gửi. Trong 3 kiểu, HMAC mạnh nhất vì secret không bao giờ đi trên đường truyền, chỉ có chữ ký tính từ secret.
Kiểu 1: HMAC_SHA256 (khuyên dùng)
MONA Pay ký thế nào
- Lấy
timestamp= unix giây lúc gửi. - Lấy
raw_body= chuỗi body đúng như sẽ gửi: JSON không khoảng trắng thừa (kiểuapplication/json) hoặc chuỗi urlencoded (kiểu form). - Tính
signature = HMAC-SHA256(secret, timestamp + "." + raw_body), xuất hex chữ thường. - Gửi kèm 2 header:
Secret là chuỗi bạn đặt khi tạo cấu hình webhook (trường Secret trong dashboard hoặc secret_key qua API). Đặt dài ít nhất 32 ký tự ngẫu nhiên, mỗi cấu hình một secret riêng.
Bạn kiểm thế nào
- Đọc raw body nguyên bản (chưa parse).
- Kiểm
|now - timestamp| <= 300giây. Lệch hơn 5 phút thì trả 400 và bỏ, đây là chống replay: kẻ gian bắt được một gói hợp lệ cũng không dùng lại được sau 5 phút. - Tính lại
sha256=+ HMAC-SHA256(secret,timestamp + "." + raw_body). - So với
X-Mona-Signaturebằng hàm so sánh thời gian cố định (hash_equals,crypto.timingSafeEqual). Khớp thì xử lý, không thì trả 401.
Lấy raw body ở từng framework
Chữ ký tính trên từng byte của body. Framework nào parse JSON rồi json_encode lại là hỏng, vì thứ tự khoá, khoảng trắng hay escape unicode có thể khác. Cách lấy raw body:
| Framework | Cách lấy raw body |
|---|---|
| PHP thuần, WordPress | file_get_contents('php://input') |
| Laravel | $request->getContent() |
| Express | express.raw({ type: 'application/json' }) ở route webhook, rồi req.body.toString('utf8') |
| Next.js (App Router) | await request.text() thay vì request.json() |
| Django | request.body (bytes) |
| FastAPI | await request.body() |
| Google Apps Script | e.postData.contents |
Trong WordPress, đăng ký route qua register_rest_route với methods => 'POST' và đọc $request->get_body().
Kiểu 2: API_KEY
MONA Pay gửi secret nguyên văn trong một header. Tên header bạn đặt khi tạo cấu hình (api_key_name), mặc định X-Webhook-Secret. Endpoint so header với secret đã lưu, khớp thì nhận.
Kiểu này đơn giản hơn HMAC nhưng secret đi trên đường truyền mỗi lần gửi và không có chống replay. Chỉ dùng khi hệ thống của bạn không tính được HMAC (một số nền tảng no-code chỉ cho so header).
Kiểu 3: NONE
Không header xác thực nào. Chỉ nên dùng ở môi trường thử nghiệm nội bộ. Nếu buộc phải dùng trên production, ít nhất hãy chặn IP: chỉ nhận request từ 103.168.55.14 (xem Địa chỉ IP) và xác minh lại giao dịch qua API đối soát trước khi giao hàng.
Danh sách kiểm tra bảo mật
- Endpoint chạy HTTPS. MONA Pay gửi tới URL HTTP thường vẫn được, nhưng payload sẽ đi qua mạng dạng rõ.
- Bật
HMAC_SHA256, secret ít nhất 32 ký tự ngẫu nhiên, lưu ở biến môi trường, không hard-code. - Từ chối timestamp lệch quá 5 phút. Đồng bộ giờ máy chủ bằng NTP, lệch giờ máy chủ là nguyên nhân hay gặp nhất khiến gói hợp lệ bị từ chối.
- So chữ ký bằng hàm thời gian cố định.
- Chống trùng bằng
transaction_codeUNIQUE. - Với giao dịch giá trị lớn, xác minh lại qua API đối soát trước khi giao hàng, dù chữ ký đã khớp.
- Đổi secret định kỳ hoặc ngay khi nghi lộ: tạo cấu hình webhook mới với secret mới, chuyển code sang, rồi xoá cấu hình cũ.
Lỗi thường gặp
Chữ ký luôn sai dù secret đúng. Body bị framework parse rồi encode lại, hoặc middleware nào đó thêm khoảng trắng. Lấy raw body theo bảng trên.
Chữ ký khớp lúc thử bằng cURL nhưng sai khi MONA Pay gửi thật. Lúc thử bạn ký bằng JSON có khoảng trắng, còn MONA Pay gửi JSON không khoảng trắng. Không cần tự tạo lại JSON, chỉ cần ký đúng chuỗi nhận được.
Gói hợp lệ bị từ chối “timestamp qua han”. Giờ máy chủ của bạn lệch hơn 5 phút. Bật đồng bộ NTP.
Dùng API_KEY mà header không thấy. Một số máy chủ (Apache với PHP-FPM) bỏ header có dấu gạch dưới hoặc đổi tên header. Đặt tên header chỉ dùng chữ và dấu gạch ngang, ví dụ X-Webhook-Secret, và kiểm bằng getallheaders().