Bối cảnh: Khi webhook “ngây thơ” gặp phải kẻ giả mạo
Hồi mình làm một dự án e-commerce, team có webhook nhận thông báo thanh toán từ Stripe. Code khá đơn giản: nhận POST request → cập nhật đơn hàng → gửi email xác nhận. Cho đến một ngày, có ai đó gửi hàng loạt request giả lên endpoint đó. Trong vòng 15 phút, hệ thống tự xử lý hơn 300 “đơn hàng thành công” không có thật — kèm 300 email xác nhận bắn thẳng đến khách hàng thật.
Vấn đề không phải ở logic nghiệp vụ. Vấn đề là webhook không có cơ chế xác thực. Bất kỳ ai biết URL đều có thể POST dữ liệu giả mạo vào.
Nhìn lại, vấn đề quy về hai điểm:
- Request giả mạo: Làm sao phân biệt request đến từ Stripe thật hay từ hacker?
- Mất dữ liệu khi retry: Server bạn down 30 giây — Stripe gửi lại, bạn xử lý 2 lần, khách bị charge kép.
HMAC-SHA256 giải quyết vấn đề đầu tiên. Cơ chế idempotency + retry thông minh giải quyết vấn đề thứ hai. Bài này tập trung vào hai thứ đó, không lan man.
Cài đặt
Môi trường: Node.js 18+, Express. Không cần thêm thư viện HMAC vì Node.js đã có module crypto built-in.
mkdir webhook-secure && cd webhook-secure
npm init -y
npm install express dotenv
Tạo file .env — và nhớ thêm vào .gitignore:
WEBHOOK_SECRET=your_super_secret_key_here
PORT=3000
WEBHOOK_SECRET là shared secret giữa bạn và service gửi webhook. Với Stripe, bạn lấy key này từ Dashboard → Webhooks → Signing secret. Giữ nó an toàn như password.
Cấu hình chi tiết
1. Xác thực HMAC: Kiểm tra “chữ ký số” của request
Cách hoạt động: khi Stripe gửi webhook, họ tính HMAC-SHA256(request_body, secret) rồi đính vào header. Server bạn làm y chang với body nhận được — nếu hai chữ ký khớp, request hợp lệ.
Điểm dễ sai nhất: phải parse raw body (Buffer), không phải JSON đã parse. Nếu Express parse JSON trước, string body thay đổi whitespace → chữ ký không khớp → 401 liên tục dù key đúng.
// webhook.js
require('dotenv').config();
const express = require('express');
const crypto = require('crypto');
const app = express();
// Raw body bắt buộc cho HMAC — không dùng express.json() ở đây
app.use('/webhook', express.raw({ type: 'application/json' }));
function verifyHmacSignature(payload, signature, secret) {
const expected = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
const sigBuf = Buffer.from(signature, 'hex');
const expBuf = Buffer.from(expected, 'hex');
// timingSafeEqual tránh timing attack — đừng dùng === ở đây
if (sigBuf.length !== expBuf.length) return false;
return crypto.timingSafeEqual(sigBuf, expBuf);
}
app.post('/webhook', (req, res) => {
const signature = req.headers['x-webhook-signature'];
if (!signature) {
return res.status(401).json({ error: 'Missing signature' });
}
const isValid = verifyHmacSignature(
req.body,
signature,
process.env.WEBHOOK_SECRET
);
if (!isValid) {
console.warn(`[WARN] Invalid signature from ${req.ip}`);
return res.status(401).json({ error: 'Invalid signature' });
}
const event = JSON.parse(req.body.toString());
// Trả 200 ngay — đừng để Stripe timeout rồi retry
res.status(200).json({ received: true });
// Xử lý async sau khi đã response
processEventIdempotent(event).catch(err =>
console.error(`[ERROR] ${event.id}: ${err.message}`)
);
});
Tại sao không dùng signature === expected? String comparison thông thường dừng ngay ở ký tự đầu tiên không khớp. Hacker gửi hàng ngàn request với signature thay đổi từng byte, đo thời gian response để đoán dần — đây là timing attack. timingSafeEqual luôn chạy đủ thời gian cố định bất kể kết quả so sánh, nên không lộ thông tin theo cách đó.
2. Idempotency Key: Xử lý đúng một lần dù nhận nhiều lần
Service gửi webhook sẽ retry nếu không nhận được 200 trong vài giây. Stripe retry theo lịch tăng dần trong vòng 3 ngày — từ 5 giây lên đến vài giờ giữa các lần thử. Trong tình huống xấu, bạn có thể nhận cùng event hơn chục lần. Không kiểm soát được điều này: charge khách 2 lần, gửi email 2 lần.
// Production nên dùng Redis. Ở đây dùng Set cho đơn giản.
const processedEvents = new Set();
async function processEventIdempotent(event) {
const eventId = event.id; // Stripe, GitHub đều có event ID duy nhất
if (processedEvents.has(eventId)) {
console.log(`[INFO] Duplicate event ${eventId}, skipping`);
return;
}
// Đánh dấu trước khi xử lý — tránh race condition với concurrent requests
processedEvents.add(eventId);
try {
await processEvent(event);
console.log(`[INFO] Event ${eventId} processed successfully`);
} catch (err) {
// Nếu lỗi, cho phép retry bằng cách xóa khỏi Set
processedEvents.delete(eventId);
throw err;
}
}
async function processEvent(event) {
switch (event.type) {
case 'payment.success':
await handlePaymentSuccess(event.data);
break;
default:
console.log(`[INFO] Unhandled event: ${event.type}`);
}
}
3. Retry với Exponential Backoff: Gọi lại khi downstream lỗi
Xác thực xong, webhook thường phải gọi sang API khác — CRM cập nhật đơn hàng, service gửi notification, hệ thống trừ kho. Nếu một trong số đó tạm thời down, không có retry thông minh là mất data.
Retry logic có 3 cái bẫy kinh điển: retry vô hạn khi service chết hẳn, không có jitter khiến hàng ngàn client đồng loạt retry cùng lúc (thundering herd), và retry cả lỗi 400 — vốn sẽ fail mãi dù thử bao nhiêu lần. Code dưới đây xử lý cả ba.
async function retryWithBackoff(fn, maxRetries = 3, baseDelay = 1000) {
let lastError;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (err) {
lastError = err;
// Không retry lỗi client (4xx) — sẽ fail mãi
if (err.statusCode >= 400 && err.statusCode < 500) throw err;
if (attempt === maxRetries) break;
// Exponential backoff + jitter: tránh nhiều client retry cùng lúc
const delay = baseDelay * Math.pow(2, attempt - 1) + Math.random() * 500;
console.log(`[WARN] Attempt ${attempt} failed. Retry in ${Math.round(delay)}ms`);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
throw new Error(`All ${maxRetries} attempts failed: ${lastError.message}`);
}
// Dùng trong handlePaymentSuccess:
async function handlePaymentSuccess(data) {
await retryWithBackoff(async () => {
const res = await fetch('https://crm.example.com/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ orderId: data.orderId })
});
if (!res.ok) {
const err = new Error(`CRM error: ${res.status}`);
err.statusCode = res.status;
throw err;
}
});
}
Kiểm tra & Monitoring
Test bằng curl
Khởi động server trước:
node webhook.js
# Webhook server running on port 3000
Tính HMAC và gửi request hợp lệ:
PAYLOAD='{"type":"payment.success","id":"evt_123","data":{"orderId":"ORD-456"}}'
SECRET="your_super_secret_key_here"
SIGNATURE=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')
curl -X POST http://localhost:3000/webhook \
-H "Content-Type: application/json" \
-H "x-webhook-signature: $SIGNATURE" \
-d "$PAYLOAD"
# {"received":true}
Test signature sai — phải trả 401:
curl -X POST http://localhost:3000/webhook \
-H "Content-Type: application/json" \
-H "x-webhook-signature: fake_signature_here" \
-d '{"type":"test"}'
# {"error":"Invalid signature"}
Test idempotency — gửi cùng event ID hai lần, lần 2 phải bị skip:
# Gửi lần 1
curl -X POST http://localhost:3000/webhook \
-H "Content-Type: application/json" \
-H "x-webhook-signature: $SIGNATURE" \
-d "$PAYLOAD"
# Gửi lần 2 — server log sẽ thấy "Duplicate event evt_123, skipping"
curl -X POST http://localhost:3000/webhook \
-H "Content-Type: application/json" \
-H "x-webhook-signature: $SIGNATURE" \
-d "$PAYLOAD"
Metrics cần theo dõi trên production
Thêm middleware log để dễ debug:
app.use('/webhook', (req, res, next) => {
const start = Date.now();
res.on('finish', () => {
console.log(JSON.stringify({
ts: new Date().toISOString(),
status: res.statusCode,
duration_ms: Date.now() - start,
ip: req.ip
}));
});
next();
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => console.log(`Webhook server running on port ${PORT}`));
Bốn con số cần nhìn vào mỗi ngày:
- Tỷ lệ 401 tăng đột biến: có thể đang bị probe tìm endpoint
- Response time > 3 giây thường xuyên: cần chuyển processing sang queue
- Duplicate event count tăng: upstream đang retry nhiều, server bạn có vấn đề gì đó
- Retry exhausted: downstream service không ổn định, cần alert ngay
Setup này đủ để chạy production cho phần lớn use case. Khi volume tăng lên hàng nghìn events/giây, đẩy events vào queue (Bull/BullMQ + Redis) thay vì xử lý inline. Logic HMAC và idempotency không cần thay đổi — chỉ thay processEvent() bằng queue.add(event) là xong.

