App của mình mở POST /webhooks/billing để cổng thanh toán báo về khi một hóa đơn đã được trả. Handler đánh dấu đơn hàng đã thanh toán rồi giao hàng. Trên staging chạy ngon lành. Rồi một ngày có người mò ra URL đó trong bundle của app mobile, xài curl gửi {"type":"invoice.paid","order_id":1234} và lấy được cái laptop miễn phí. Endpoint webhook là một URL public, không có xác thực, mà lại đổi trạng thái trong hệ thống của mình. Không verify chữ ký thì ai đoán được format payload cũng điều khiển được nó.
Hầu hết provider nghiêm túc (Stripe, GitHub, Shopify, mấy service xài Svix, Paddle) đều giải bài này giống nhau: họ ký mỗi request bằng HMAC với một secret chỉ mình và họ biết. Verify chữ ký đó cho đúng chỉ tốn khoảng 40 dòng code, nhưng có bốn chi tiết người ta sai đi sai lại hoài: raw body, so sánh timing-safe, khung thời gian của timestamp và chống replay.
Chữ ký HMAC của webhook hoạt động ra sao
HMAC-SHA256 nhận một secret key và một message rồi sinh ra một tag 32 byte. Không có key thì không tạo được tag hợp lệ cho message đã bị sửa, và cũng không suy ngược ra key từ mấy tag đã thấy. Provider tính tag trên đúng từng byte họ gửi và đặt vô header. Mình tính lại trên đúng từng byte mình nhận được rồi so sánh.
Một scheme điển hình trông như vầy (tên header mỗi provider mỗi khác, cấu trúc thì gần như y chang):
Webhook-Id: evt_01JC8Z4K
Webhook-Signature: t=1761900000,v1=<64 lowercase hex chars>
signed string = "1761900000" + "." + <exact raw request body bytes>
v1 = hex( HMAC-SHA256( secret, signed string ) )
Có hai điểm làm scheme này mạnh hơn kiểu "HMAC cái body là xong". Timestamp nằm trong chuỗi được ký, nên kẻ tấn công không thể lấy một request cũ bắt được rồi sửa timestamp. Và header có thể mang nhiều giá trị v1, cho phép provider ký bằng cả secret cũ lẫn secret mới trong lúc xoay vòng secret.
Luồng verify
Thứ tự là quan trọng. Kiểm tra rẻ trước (timestamp), rồi tới kiểm tra mật mã, rồi mới tới kiểm tra replay có trạng thái. Chỉ request nào qua đủ ba bước mới tới được controller. Để ý cái không nằm trong danh sách: parse JSON. Mình verify byte trước, xong rồi mới tin và decode nó.
Viết middleware trong Laravel
Đặt phần verify vô middleware để mọi route webhook đều được bảo đảm giống nhau, còn controller chỉ lo business logic. Tên provider là tham số của middleware, nên một class xài được cho mọi tích hợp.
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Symfony\Component\HttpFoundation\Response;
final class VerifyWebhookSignature
{
private const TOLERANCE_SECONDS = 300;
public function handle(Request $request, Closure $next, string $provider): Response
{
$secrets = (array) config("services.{$provider}.webhook_secrets", []);
$header = (string) $request->header('Webhook-Signature', '');
$parts = $this->parseHeader($header);
$timestamp = (int) ($parts['t'][0] ?? 0);
abort_if(abs(now()->getTimestamp() - $timestamp) > self::TOLERANCE_SECONDS, 401, 'Stale timestamp.');
// getContent() is the raw body. Never rebuild it from $request->all().
$payload = $request->getContent();
abort_unless($this->matchesAny($secrets, "{$timestamp}.{$payload}", $parts['v1'] ?? []), 401, 'Bad signature.');
$key = "webhook:{$provider}:".$request->header('Webhook-Id', hash('sha256', $header));
if (! Cache::add($key, true, self::TOLERANCE_SECONDS * 2)) {
return response()->noContent(); // replay or duplicate delivery: ack, do nothing
}
$response = $next($request);
if (! $response->isSuccessful()) {
Cache::forget($key); // let the provider's retry through
}
return $response;
}
/** @return array<string, list<string>> */
private function parseHeader(string $header): array
{
$parts = [];
foreach (explode(',', $header) as $item) {
[$key, $value] = array_pad(explode('=', trim($item), 2), 2, '');
$parts[$key][] = $value;
}
return $parts;
}
/** @param list<string> $signatures */
private function matchesAny(array $secrets, string $signed, array $signatures): bool
{
foreach ($secrets as $secret) {
$expected = hash_hmac('sha256', $signed, $secret);
foreach ($signatures as $signature) {
if (hash_equals($expected, $signature)) {
return true;
}
}
}
return false;
}
}
Mấy lựa chọn có chủ đích trong đó:
$request->getContent()trả về body đúng y như lúc nhận.json_encode($request->all())thì không: thứ tự key, khoảng trắng, cách escape dấu/hay Unicode đều có thể khác, và chữ ký sẽ fail lúc được lúc không tùy payload.hash_equals()so sánh trong thời gian hằng số. Còn===dừng ngay ở byte khác đầu tiên, về lý thuyết là lộ ra kẻ tấn công đã đoán đúng bao nhiêu ký tự đầu. Qua mạng thì tín hiệu này nhiễu lắm, nhưng tránh được mà không tốn gì, nên cứ tránh.- Secret lấy từ
config('services.billing.webhook_secrets'), là một mảng. Lúc xoay vòng secret thì set nó thànharray_filter([env('BILLING_WEBHOOK_SECRET'), env('BILLING_WEBHOOK_SECRET_PREVIOUS')]), đợi provider xác nhận đã chuyển xong thì bỏ cái cũ. - Chống replay xài
Cache::add(), chỉ ghi khi key chưa tồn tại, và atomic trên Redis, Memcached lẫn database store. TTL chỉ cần dài hơn khung timestamp: quá 5 phút thì bước kiểm tra timestamp đã chặn rồi. - Nếu controller lỗi thì key bị xóa, để lần retry của provider được xử lý thay vì bị nuốt mất.
Đăng ký alias rồi gắn vô route:
// bootstrap/app.php
use App\Http\Middleware\VerifyWebhookSignature;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
)
->withMiddleware(function (Middleware $middleware): void {
$middleware->alias(['webhook.signed' => VerifyWebhookSignature::class]);
})
->withExceptions(function (Exceptions $exceptions): void {
//
})
->create();
// routes/api.php
use App\Http\Controllers\BillingWebhookController;
use Illuminate\Support\Facades\Route;
Route::post('/webhooks/billing', BillingWebhookController::class)
->middleware('webhook.signed:billing');
Route trong routes/api.php không chạy CSRF. Nếu để route webhook trong web.php thì phải loại nó ra bằng $middleware->validateCsrfTokens(except: ['webhooks/*']), không thì lần gửi nào cũng ăn lỗi 419.
Kiểm tra tương tự trong Node.js và Python
Với Express, bug kinh điển là một app.use(express.json()) global đã đọc và parse body trước khi route của mình kịp thấy. Xài express.raw() trên route webhook để req.body vẫn là Buffer.
const crypto = require('node:crypto');
const express = require('express');
const app = express();
function safeEqualHex(a, b) {
const left = Buffer.from(a, 'hex');
const right = Buffer.from(b, 'hex');
// timingSafeEqual throws on different lengths, so check first
return left.length === right.length && crypto.timingSafeEqual(left, right);
}
// express.raw keeps req.body as a Buffer; register it BEFORE any global express.json()
app.post('/webhooks/billing', express.raw({ type: 'application/json' }), (req, res) => {
const parts = Object.fromEntries(
(req.get('Webhook-Signature') ?? '').split(',').map((p) => p.trim().split('=', 2)),
);
const ts = Number(parts.t);
if (!Number.isInteger(ts) || Math.abs(Date.now() / 1000 - ts) > 300) {
return res.sendStatus(401);
}
const expected = crypto
.createHmac('sha256', process.env.BILLING_WEBHOOK_SECRET)
.update(`${ts}.`)
.update(req.body)
.digest('hex');
if (!safeEqualHex(expected, parts.v1 ?? '')) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString('utf8'));
// enqueue event.id + payload, then answer quickly
res.sendStatus(200);
});
crypto.timingSafeEqual() sẽ throw RangeError khi hai buffer khác độ dài, và kẻ tấn công gây ra được chuyện đó chỉ bằng một chữ ký ngắn. Kiểm tra độ dài trước và coi lệch độ dài là không hợp lệ. Bên Python, hmac.compare_digest() tự lo vụ khác độ dài:
import hashlib
import hmac
import time
def verify_webhook(secret: str, header: str, body: bytes, tolerance: int = 300) -> bool:
parts: dict[str, list[str]] = {}
for item in header.split(","):
key, _, value = item.strip().partition("=")
parts.setdefault(key, []).append(value)
raw_ts = parts.get("t", [""])[0]
if not raw_ts.isdigit() or abs(time.time() - int(raw_ts)) > tolerance:
return False
expected = hmac.new(secret.encode(), raw_ts.encode() + b"." + body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, sig) for sig in parts.get("v1", []))
Với Django hay Flask thì xài request.body / request.get_data(), là bytes, đừng bao giờ xài JSON đã parse.
Kiểm thử middleware (test)
Test quan trọng là mấy test phủ định: request hợp lệ thì dễ chạy đúng một cách tình cờ, còn chặn được request giả mạo mới là thứ mình thật sự cần. Với Pest, mình đăng ký một route tạm ngay trong test rồi gửi raw content vô:
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Route;
beforeEach(function () {
config(['services.billing.webhook_secrets' => ['whsec_test']]);
Route::post('/_test/webhook', function () {
Cache::increment('handled');
return response()->noContent();
})->middleware('webhook.signed:billing');
});
function sendWebhook(string $body, int $timestamp, string $secret = 'whsec_test'): Illuminate\Testing\TestResponse
{
$signature = hash_hmac('sha256', "{$timestamp}.{$body}", $secret);
return test()->call('POST', '/_test/webhook', [], [], [], [
'HTTP_WEBHOOK_SIGNATURE' => "t={$timestamp},v1={$signature}",
'HTTP_WEBHOOK_ID' => 'evt_1',
'CONTENT_TYPE' => 'application/json',
], $body);
}
it('accepts a correctly signed payload once', function () {
$body = '{"id":"evt_1","type":"invoice.paid"}';
sendWebhook($body, now()->getTimestamp())->assertNoContent();
sendWebhook($body, now()->getTimestamp())->assertNoContent(); // replay
expect(Cache::get('handled'))->toBe(1);
});
it('rejects a tampered body', function () {
$signature = hash_hmac('sha256', now()->getTimestamp().'.{"amount":100}', 'whsec_test');
test()->call('POST', '/_test/webhook', [], [], [], [
'HTTP_WEBHOOK_SIGNATURE' => 't='.now()->getTimestamp().",v1={$signature}",
], '{"amount":1000000}')->assertUnauthorized();
});
it('rejects stale timestamps and wrong secrets', function () {
sendWebhook('{}', now()->subMinutes(6)->getTimestamp())->assertUnauthorized();
sendWebhook('{}', now()->getTimestamp(), 'whsec_wrong')->assertUnauthorized();
expect(Cache::get('handled'))->toBeNull();
});
Mấy helper đồng hồ của Laravel ($this->travel() / travelTo()) xài được ở đây vì middleware gọi now() chứ không phải time(). Nếu test chạy với Redis thật thì nhớ flush mấy key replay giữa các test, không thì test sau sẽ bị coi là replay của test trước.
Mấy lỗi hay gặp
- Verify trên JSON đã encode lại. Luôn hash raw byte. Đây là nguyên nhân số một của chuyện "chữ ký chạy trong test mà fail với vài event trên production".
- So sánh bằng
==. Ngoài chuyện lộ thời gian, so sánh lỏng của PHP còn coi mấy chuỗi hex dạng"0e1234..."là số bằng nhau. Xàihash_equals(). - Không kiểm tra timestamp. Một request hợp lệ bị bắt từ log hay proxy có thể bị replay mãi mãi. Chữ ký kiểu GitHub (
X-Hub-Signature-256: sha256=...) không ký timestamp, nên với loại đó thì dedup theo header delivery id. - Khung thời gian quá chặt. Lệch đồng hồ và retry của provider là chuyện có thật. Năm phút là mặc định phổ biến; đừng xuống dưới một phút.
- Làm việc nặng trước khi trả response. Provider timeout sau vài giây rồi retry, vậy là mình nhận trùng. Verify, lưu hoặc đẩy event vô queue, trả 2xx, rồi xử lý trong job.
- Tin mấy field không được ký. Header như
Webhook-Idchỉ an toàn để dedup khi provider đưa nó vô chuỗi ký, hoặc mình dedup theo event id nằm trong body đã verify. - Lộ secret. Đừng log nguyên header, đừng commit
.env, và dùng secret riêng cho từng môi trường, từng tenant để staging có bị lộ cũng không giả được event production.
Khi nào nên xài (và khi nào không)
Xài HMAC verify cho mọi webhook đi vô từ provider có hỗ trợ, và cho cả webhook mình gửi đi cho khách hàng: ký request gửi đi theo cùng cách để họ verify được mình.
HMAC không phải công cụ đúng khi:
- Bên gửi không giữ được bí mật. Browser và app mobile không giữ được HMAC key; ai cũng móc ra được. Hãy xác thực user thay vì vậy.
- Nhiều bên nhận độc lập cùng phải verify một message. Secret dùng chung nghĩa là bên nhận nào cũng giả được message. Xài chữ ký bất đối xứng (Ed25519, hoặc RS256/ES256 như trong JWT) để chỉ bên gửi giữ private key.
- Cần bảo mật nội dung. HMAC chứng minh ai gửi và byte không bị sửa. Nó không giấu nội dung. Đường truyền vẫn phải có TLS.
Thêm một lớp nữa, vài provider công bố dải IP nguồn của họ. Allowlist IP là thứ có thêm thì tốt, nhưng không thay được chữ ký: dải IP thay đổi, còn chữ ký mới là thứ thật sự chứng minh nguồn gốc.
Thử thách
Luyện tập ngay điều vừa học. Viết lời giải, mở gợi ý nếu bí.
Đề bài
Viết hai hàm (PHP: signWebhook / verifyWebhook, Python: sign_webhook / verify_webhook, JavaScript: signWebhook / verifyWebhook). sign(secret, timestamp, body) trả về giá trị header 't=<timestamp>,v1=<hex>' với hex là HMAC-SHA256 dạng hex chữ thường của '<timestamp>.<body>', key là secret (body bên Python là bytes, bên PHP và JS là string). verify(secret, header, body, now, tolerance = 300) trả về boolean. Parse header thành các cặp key=value cách nhau bởi dấu phẩy, trim khoảng trắng quanh từng cặp; một key có thể xuất hiện nhiều lần (nhiều giá trị v1). Trả false nếu thiếu t hoặc t không phải toàn chữ số, hoặc trị tuyệt đối của now trừ t lớn hơn tolerance. Trả true nếu BẤT KỲ giá trị v1 nào khớp chữ ký mong đợi, so sánh constant-time (hash_equals, hmac.compare_digest, crypto.timingSafeEqual có kiểm tra độ dài). Còn lại trả false. Không được throw với header sai định dạng.
Code khởi tạo
function signWebhook(string $secret, int $timestamp, string $body): string
{
// TODO: return "t=<timestamp>,v1=<hex HMAC-SHA256 of "<timestamp>.<body>">"
throw new LogicException('TODO');
}
function verifyWebhook(string $secret, string $header, string $body, int $now, int $tolerance = 300): bool
{
// TODO: parse "key=value" pairs separated by commas (v1 may repeat)
// TODO: reject a missing / non-numeric t, or |now - t| > tolerance
// TODO: accept if ANY v1 equals the expected signature (use hash_equals)
throw new LogicException('TODO');
}
import hashlib
import hmac
def sign_webhook(secret: str, timestamp: int, body: bytes) -> str:
# TODO: return "t=<timestamp>,v1=<hex HMAC-SHA256 of b"<timestamp>." + body>"
raise NotImplementedError
def verify_webhook(secret: str, header: str, body: bytes, now: int, tolerance: int = 300) -> bool:
# TODO: parse comma-separated key=value pairs (v1 may repeat)
# TODO: reject a missing / non-numeric t, or abs(now - t) > tolerance
# TODO: accept if ANY v1 matches, compared with hmac.compare_digest
raise NotImplementedError
const crypto = require('node:crypto');
function signWebhook(secret, timestamp, body) {
// TODO: return `t=${timestamp},v1=<hex HMAC-SHA256 of "<timestamp>.<body>">`
throw new Error('TODO');
}
function verifyWebhook(secret, header, body, now, tolerance = 300) {
// TODO: parse comma-separated key=value pairs (v1 may repeat)
// TODO: reject a missing / non-numeric t, or |now - t| > tolerance
// TODO: accept if ANY v1 matches; use crypto.timingSafeEqual (equal lengths only!)
throw new Error('TODO');
}
Lời giải của bạn
Gợi ý
Đã mở hết gợi ýKý đúng chuỗi timestamp + '.' + body; dư một byte thôi, kể cả khoảng trắng cuối, cũng phải làm verify fail
Gom giá trị theo từng key thành list để thử hết mấy v1 lặp lại (lúc xoay vòng secret)
crypto.timingSafeEqual sẽ throw khi khác độ dài, nên so độ dài trước và coi lệch là false
function signWebhook(string $secret, int $timestamp, string $body): string
{
return "t={$timestamp},v1=".hash_hmac('sha256', "{$timestamp}.{$body}", $secret);
}
function verifyWebhook(string $secret, string $header, string $body, int $now, int $tolerance = 300): bool
{
$parts = [];
foreach (explode(',', $header) as $item) {
[$key, $value] = array_pad(explode('=', trim($item), 2), 2, '');
$parts[$key][] = $value;
}
$t = $parts['t'][0] ?? '';
if (! ctype_digit($t) || abs($now - (int) $t) > $tolerance) {
return false;
}
$expected = hash_hmac('sha256', "{$t}.{$body}", $secret);
foreach ($parts['v1'] ?? [] as $signature) {
if (hash_equals($expected, $signature)) {
return true;
}
}
return false;
}
import hashlib
import hmac
def sign_webhook(secret: str, timestamp: int, body: bytes) -> str:
mac = hmac.new(secret.encode(), f"{timestamp}.".encode() + body, hashlib.sha256)
return f"t={timestamp},v1={mac.hexdigest()}"
def verify_webhook(secret: str, header: str, body: bytes, now: int, tolerance: int = 300) -> bool:
parts: dict[str, list[str]] = {}
for item in header.split(","):
key, _, value = item.strip().partition("=")
parts.setdefault(key, []).append(value)
t = parts.get("t", [""])[0]
if not (t.isascii() and t.isdigit()) or abs(now - int(t)) > tolerance:
return False
expected = hmac.new(secret.encode(), t.encode() + b"." + body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, sig) for sig in parts.get("v1", []))
const crypto = require('node:crypto');
function signWebhook(secret, timestamp, body) {
const sig = crypto.createHmac('sha256', secret).update(`${timestamp}.${body}`).digest('hex');
return `t=${timestamp},v1=${sig}`;
}
function verifyWebhook(secret, header, body, now, tolerance = 300) {
const parts = {};
for (const item of header.split(',')) {
const trimmed = item.trim();
const i = trimmed.indexOf('=');
const key = i === -1 ? trimmed : trimmed.slice(0, i);
const value = i === -1 ? '' : trimmed.slice(i + 1);
(parts[key] ??= []).push(value);
}
const t = (parts.t ?? [''])[0];
if (!/^\d+$/.test(t) || Math.abs(now - Number(t)) > tolerance) {
return false;
}
const expected = Buffer.from(
crypto.createHmac('sha256', secret).update(`${t}.${body}`).digest('hex'),
);
return (parts.v1 ?? []).some((sig) => {
const given = Buffer.from(sig);
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
});
}

