Masalah paling umum saat memverifikasi webhook bukan pada algoritma HMAC, melainkan pada kontrak data yang dipakai untuk menghitung signature. Jika provider menandatangani raw request body, maka server Anda juga harus menghitung HMAC dari byte mentah yang sama. Begitu body diparse menjadi JSON, diserialisasi ulang, diubah encoding-nya, atau header penting dinormalisasi secara keliru, hasil signature bisa berbeda walaupun isi datanya tampak identik.
Di Bun, pendekatan yang aman adalah: baca body mentah sekali, verifikasi signature terhadap byte asli, validasi timestamp dengan toleransi yang jelas, lakukan replay protection, lalu baru proses payload. Setelah signature valid, jangan lupa bahwa webhook provider bisa dikirim ulang; karena itu Anda tetap memerlukan idempotensi di level bisnis.
Mengapa verifikasi signature webhook sering false negative
False negative berarti request yang sebenarnya sah justru ditolak. Penyebabnya biasanya salah satu dari berikut:
- Body sudah diparse lalu diserialisasi ulang. JSON yang sama secara semantik belum tentu sama secara byte.
- Perubahan encoding atau newline. Misalnya provider mengirim UTF-8 tertentu, tetapi aplikasi mengubah representasi string sebelum menghitung HMAC.
- Header diperlakukan terlalu bebas. Nama header HTTP memang tidak peka huruf besar-kecil, tetapi nilai header yang ikut ditandatangani tidak boleh diubah sembarangan.
- Kontrak signature provider tidak diikuti. Ada provider yang menandatangani body saja, ada yang menggabungkan timestamp dan body, misalnya
timestamp + "." + body. - Perbandingan signature tidak konstan waktu. Ini bukan false negative secara langsung, tetapi praktik perbandingan string biasa tetap sebaiknya dihindari.
Intinya: sebelum menulis kode, pastikan Anda tahu persis apa yang ditandatangani provider. Jangan menebak.
Kontrak request mentah: yang harus dijaga tetap sama
Jika dokumentasi provider menyebut signature dihitung dari raw body, maka yang harus Anda pertahankan adalah urutan byte asli. Ini berarti:
- Jangan panggil
request.json()sebelum verifikasi. - Jangan hitung HMAC dari
JSON.stringify(obj). - Jangan mengubah spasi, urutan properti, newline, atau escaping.
- Jangan berasumsi bahwa dua JSON yang terlihat sama di editor akan menghasilkan byte yang sama.
Contoh sederhana:
{"a":1,"b":2}
{"b":2,"a":1}
Dua payload di atas setara sebagai objek JSON, tetapi byte-nya berbeda. Jika provider menandatangani payload pertama dan server Anda mem-parse lalu menyusun ulang menjadi payload kedua, verifikasi akan gagal.
Catatan: verifikasi signature adalah validasi integritas transport request, bukan validasi struktur bisnis payload. Keduanya penting, tetapi urutannya berbeda.
Implementasi Bun HTTP server yang aman
Berikut contoh server Bun yang membaca raw body, memverifikasi signature, mengecek timestamp, dan baru kemudian memproses JSON. Contoh ini sengaja generik karena format header tiap provider bisa berbeda.
const secret = Bun.env.WEBHOOK_SECRET;
if (!secret) {
throw new Error("WEBHOOK_SECRET belum diatur");
}
Bun.serve({
port: 3000,
async fetch(req) {
const url = new URL(req.url);
if (req.method !== "POST" || url.pathname !== "/webhooks/provider") {
return new Response("Not Found", { status: 404 });
}
let rawBody;
try {
rawBody = await req.text();
} catch {
return jsonError(400, "invalid_request", "Request body tidak dapat dibaca");
}
const signatureHeader = req.headers.get("x-signature");
const timestampHeader = req.headers.get("x-timestamp");
const eventIdHeader = req.headers.get("x-event-id");
const result = await verifyWebhookSignature({
rawBody,
secret,
signatureHeader,
timestampHeader,
toleranceSeconds: 300,
replayKey: eventIdHeader ? `provider:${eventIdHeader}` : undefined,
});
if (!result.ok) {
return jsonError(result.status, result.code, result.message);
}
let payload;
try {
payload = JSON.parse(rawBody);
} catch {
return jsonError(400, "invalid_json", "Payload JSON tidak valid");
}
const accepted = await markEventProcessed(result.eventKey ?? digestForFallback(rawBody));
if (!accepted) {
return new Response(null, { status: 200 });
}
try {
await handleWebhookEvent(payload);
return new Response(null, { status: 200 });
} catch (err) {
console.error("Webhook processing error", err);
return jsonError(500, "processing_error", "Gagal memproses webhook");
}
},
});
function jsonError(status, code, message) {
return new Response(JSON.stringify({ error: code, message }), {
status,
headers: { "content-type": "application/json; charset=utf-8" },
});
}
async function handleWebhookEvent(payload) {
console.log("event:", payload.type ?? "unknown");
}
Poin penting dari contoh di atas:
req.text()dipanggil sebelum parsing JSON.- Raw body disimpan apa adanya dan dipakai untuk verifikasi.
- JSON baru diparse setelah signature lolos.
- Idempotensi dipisahkan dari verifikasi cryptographic.
Util verifikasi signature yang rapi
Contoh berikut memakai HMAC-SHA256 dengan format umum: signature dihitung dari gabungan timestamp + "." + rawBody. Sesuaikan format ini dengan dokumentasi provider Anda. Jika provider menandatangani body saja, ubah fungsi buildSignedPayload.
async function verifyWebhookSignature({
rawBody,
secret,
signatureHeader,
timestampHeader,
toleranceSeconds = 300,
replayKey,
}) {
if (!signatureHeader || !timestampHeader) {
return fail(400, "missing_signature", "Header signature atau timestamp tidak ada");
}
const timestamp = Number(timestampHeader);
if (!Number.isFinite(timestamp)) {
return fail(400, "invalid_timestamp", "Format timestamp tidak valid");
}
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > toleranceSeconds) {
return fail(401, "expired_signature", "Timestamp di luar toleransi");
}
const expected = await hmacHex(secret, buildSignedPayload(timestampHeader, rawBody));
const provided = normalizeSignature(signatureHeader);
if (!provided) {
return fail(400, "invalid_signature", "Format signature tidak valid");
}
if (!timingSafeEqualHex(expected, provided)) {
return fail(401, "signature_mismatch", "Signature tidak cocok");
}
if (replayKey) {
const fresh = await reserveReplayKey(replayKey, toleranceSeconds);
if (!fresh) {
return fail(409, "replay_detected", "Webhook replay terdeteksi");
}
}
return {
ok: true,
status: 200,
eventKey: replayKey,
};
}
function fail(status, code, message) {
return { ok: false, status, code, message };
}
function buildSignedPayload(timestamp, rawBody) {
return `${timestamp}.${rawBody}`;
}
function normalizeSignature(headerValue) {
const value = headerValue.trim();
if (value.startsWith("sha256=")) {
return value.slice("sha256=".length).toLowerCase();
}
return /^[a-fA-F0-9]+$/.test(value) ? value.toLowerCase() : null;
}
async function hmacHex(secret, message) {
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"]
);
const signature = await crypto.subtle.sign(
"HMAC",
key,
new TextEncoder().encode(message)
);
return toHex(new Uint8Array(signature));
}
function toHex(bytes) {
return Array.from(bytes, b => b.toString(16).padStart(2, "0")).join("");
}
function timingSafeEqualHex(a, b) {
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) {
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
}
return diff === 0;
}
Mengapa pendekatan ini bekerja
- HMAC memastikan payload tidak berubah dan hanya pihak yang punya secret yang bisa menghasilkan signature valid.
- Timestamp tolerance membatasi jendela waktu agar signature lama tidak bisa dipakai bebas selamanya.
- Replay protection mencegah request identik yang sudah valid diputar ulang berkali-kali dalam window tertentu.
- Timing-safe comparison mengurangi kebocoran informasi dari perbandingan string biasa.
Replay protection dan idempotensi bukan hal yang sama
Ini sering tercampur, padahal fungsinya berbeda:
- Replay protection: lapisan keamanan. Menolak request sah yang dikirim ulang secara mencurigakan dalam rentang waktu tertentu.
- Idempotensi: lapisan bisnis. Mencegah event provider yang sama diproses dua kali karena retry normal, race condition, atau pengiriman ulang yang memang sah.
Banyak provider akan me-retry webhook jika endpoint Anda lambat, timeout, atau mengembalikan 5xx. Karena itu, walaupun signature valid, Anda tetap perlu menyimpan event ID atau kunci unik lain agar pemrosesan hanya terjadi sekali.
Untuk contoh sederhana, Anda bisa memakai penyimpanan sementara di memori saat lokal, tetapi pada sistem nyata gunakan store bersama seperti Redis atau database dengan unique constraint. Prinsip yang dicari adalah operasi atomik: reserve once, process once.
const replayCache = new Map();
const processedEvents = new Set();
async function reserveReplayKey(key, ttlSeconds) {
cleanupExpiredReplayKeys();
if (replayCache.has(key)) return false;
replayCache.set(key, Date.now() + ttlSeconds * 1000);
return true;
}
function cleanupExpiredReplayKeys() {
const now = Date.now();
for (const [key, expiresAt] of replayCache.entries()) {
if (expiresAt <= now) replayCache.delete(key);
}
}
async function markEventProcessed(key) {
if (processedEvents.has(key)) {
return false;
}
processedEvents.add(key);
return true;
}
async function digestForFallback(rawBody) {
const bytes = new TextEncoder().encode(rawBody);
const hash = await crypto.subtle.digest("SHA-256", bytes);
return toHex(new Uint8Array(hash));
}
Contoh di atas cukup untuk demonstrasi, tetapi tidak cocok untuk multi-instance atau restart process. Dalam produksi, gunakan penyimpanan persisten atau terdistribusi.
Edge case umum integrasi webhook
1. JSON sama, byte berbeda
Kasus paling klasik. Provider menandatangani byte mentah berikut:
{"amount":1000,"currency":"IDR"}
Di server, Anda memanggil JSON.parse lalu JSON.stringify. Hasilnya bisa saja menjadi:
{"currency":"IDR","amount":1000}
Secara objek sama, tetapi urutan properti berbeda. Jika HMAC dihitung dari hasil serialisasi ulang, signature pasti mismatch.
2. Proxy mengubah atau meneruskan header secara tidak konsisten
Header HTTP tidak peka huruf besar-kecil pada nama, jadi X-Signature dan x-signature harus dianggap sama. Namun nilainya jangan dinormalisasi sembarangan. Hal yang perlu diperhatikan:
- Pastikan reverse proxy atau gateway tidak menghapus header custom dari provider.
- Jangan memangkas nilai header selain trimming ringan yang memang diizinkan oleh format provider.
- Jika provider mengirim beberapa signature dalam satu header, parse sesuai spesifikasinya, jangan ambil sembarang substring.
Jika webhook melewati CDN, load balancer, atau ingress, pastikan aturan forwarding header sudah benar. Banyak bug verifikasi ternyata hanya karena header signature tidak sampai ke aplikasi.
3. Retry provider setelah timeout atau 5xx
Webhook yang valid bisa dikirim berkali-kali. Jangan menganggap duplikasi sebagai serangan terlebih dahulu. Biasanya alurnya seperti ini:
- Provider kirim event.
- Endpoint Anda lambat atau error.
- Provider retry dengan payload dan signature baru atau sama, tergantung implementasi provider.
Solusinya:
- Verifikasi signature setiap request.
- Terapkan idempotensi berdasarkan event ID provider jika tersedia.
- Kembalikan status 2xx sesegera mungkin jika event sudah pernah diproses.
4. Body dibaca lebih dari sekali
Di banyak runtime Fetch API, body request adalah stream yang pada praktiknya tidak boleh diasumsikan bisa dibaca berulang. Pola aman adalah baca sekali ke string atau byte buffer, simpan, pakai untuk verifikasi, lalu parse dari salinan itu. Jangan campur req.text() dan req.json() pada request yang sama tanpa strategi yang jelas.
5. Encoding karakter non-ASCII
Jika payload bisa berisi karakter Unicode, perbedaan encoding atau transformasi string dapat memengaruhi HMAC. Karena itu, gunakan representasi mentah yang diterima server dan encode secara konsisten saat menghitung HMAC. Hindari manipulasi teks sebelum verifikasi.
Respons error yang aman dan operasional
Saat verifikasi gagal, respons sebaiknya cukup informatif untuk debugging internal, tetapi tidak membuka detail sensitif. Rekomendasinya:
- 400 Bad Request untuk header hilang atau format timestamp/signature salah.
- 401 Unauthorized untuk signature mismatch atau timestamp di luar toleransi.
- 409 Conflict untuk replay yang terdeteksi, jika Anda ingin membedakannya.
- 500 Internal Server Error hanya untuk kegagalan internal saat memproses event yang sebenarnya sudah lolos verifikasi.
Di body respons, hindari mengembalikan nilai expected signature, secret, atau detail internal lainnya. Cukup beri kode error yang stabil untuk logging dan observability.
{
"error": "signature_mismatch",
"message": "Signature tidak cocok"
}
Di log internal, simpan informasi yang membantu investigasi, misalnya:
- request ID
- event ID provider
- timestamp header
- hash dari raw body, bukan body penuh jika sensitif
- alasan gagal verifikasi
Jangan log secret atau payload mentah penuh jika berisi data sensitif.
Checklist debugging verifikasi signature webhook di Bun
Jika verifikasi masih gagal, periksa checklist ini secara berurutan:
- Apakah Anda menghitung HMAC dari raw body asli? Bukan dari objek JSON hasil parse.
- Apakah format string yang ditandatangani benar? Body saja, atau
timestamp.body, atau format lain sesuai provider. - Apakah header signature benar-benar sampai ke aplikasi? Cek log header setelah proxy/gateway.
- Apakah timestamp tolerance masuk akal? Periksa sinkronisasi waktu server.
- Apakah Anda memakai secret yang benar untuk environment yang benar? Sandbox dan production sering berbeda.
- Apakah ada prefix seperti
sha256=yang belum diparse? - Apakah body terbaca sekali lalu berubah? Hindari memanggil parser lain sebelum verifikasi.
- Apakah ada whitespace ekstra atau transformasi karakter? Cek payload mentah byte per byte bila perlu.
- Apakah event sebenarnya replay atau retry? Lihat event ID dan mekanisme deduplikasi.
Teknik debugging yang sangat berguna adalah membandingkan:
- signature yang diterima
- signature yang Anda hitung
- string exact yang Anda tandatangani
- hash SHA-256 dari raw body untuk memastikan body tidak berubah antar lapisan
Untuk lingkungan non-produksi, Anda bisa log hex digest dari raw body agar lebih aman daripada log seluruh payload.
Pengujian lokal dengan payload mentah
Untuk menguji implementasi, jangan hanya kirim objek JSON dari tool lalu membiarkan tool tersebut menyusun ulang body. Simulasikan payload mentah yang benar-benar sama dengan yang akan ditandatangani.
Contoh skrip Bun kecil untuk membuat signature lokal:
const secret = "dev-secret";
const timestamp = String(Math.floor(Date.now() / 1000));
const rawBody = '{"order_id":"ord_123","amount":15000,"currency":"IDR"}';
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"]
);
const data = `${timestamp}.${rawBody}`;
const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(data));
const hex = Array.from(new Uint8Array(sig), b => b.toString(16).padStart(2, "0")).join("");
console.log({ timestamp, rawBody, signature: `sha256=${hex}` });
Lalu kirim request dengan body yang sama persis. Jika memakai curl, lebih aman mengambil body dari file agar format tidak berubah tanpa sadar:
curl -i \
-X POST http://localhost:3000/webhooks/provider \
-H 'Content-Type: application/json' \
-H 'X-Timestamp: 1710000000' \
-H 'X-Signature: sha256=...' \
--data-binary @payload.json
Mengapa --data-binary berguna? Karena ia membantu mengirim isi file lebih mentah, termasuk newline, dibanding pendekatan yang berpotensi mengubah representasi body. Untuk debugging verifikasi, detail seperti ini penting.
Strategi uji yang disarankan
- Buat fixture payload mentah di file.
- Hitung signature dari file yang sama, bukan dari objek yang dibuat ulang di kode lain.
- Uji skenario sukses, signature salah, timestamp kedaluwarsa, header hilang, dan replay.
- Uji payload dengan karakter Unicode dan spasi/newline berbeda.
Rekomendasi implementasi praktis
- Simpan raw body terlebih dahulu. Ini fondasi utama agar tidak terjadi false negative.
- Buat util verifikasi terpisah. Jangan campur logika cryptographic dengan logika bisnis handler.
- Definisikan tolerance dan replay window secara eksplisit. Nilai umum sering berada di kisaran beberapa menit, tetapi ikuti provider dan kebutuhan sistem Anda.
- Gunakan idempotensi berbasis event ID. Signature valid tidak berarti event belum pernah diproses.
- Log secukupnya untuk investigasi. Utamakan request ID, event ID, dan hash body.
- Uji dengan payload mentah. Jangan mengandalkan serialisasi ulang saat testing.
Penutup
Verifikasi signature webhook di Bun akan stabil jika Anda menjaga satu prinsip utama: hitung signature dari representasi request yang sama persis dengan yang ditandatangani provider. Dari sana, tambahkan timestamp tolerance, replay protection, dan idempotensi agar integrasi tetap aman dan tahan terhadap retry normal.
Jika Anda sering melihat mismatch padahal secret sudah benar, curigai raw body lebih dulu. Dalam praktik, parsing terlalu dini, perubahan byte JSON, dan header yang tidak diteruskan dengan benar adalah sumber false negative yang paling sering.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!