2026-08-29 · 10 min
Webhook Security: HMAC Signature, Idempotency Key, dan Replay Attack
Sistem pembayaran klien Jakarta saya pernah memproses satu notifikasi transfer dua kali dalam lima menit — dua email konfirmasi terkirim ke pengguna, dan saldo kredit dobel. Penyebabnya bukan bug di kode bisnis, tapi tidak ada perlindungan sama sekali di endpoint webhook mereka. Request masuk, langsung diproses, selesai. Tidak ada verifikasi pengirim, tidak ada deteksi duplikat, tidak ada batas waktu validitas.
Webhook security itu bukan satu fitur — ini tiga lapisan independen yang masing-masing mencegah ancaman berbeda. Artikel ini membahas ketiga lapisan itu secara konkret: HMAC signature untuk autentikasi pengirim, idempotency key untuk mencegah pemrosesan ganda, dan timestamp window untuk menutup celah replay attack.
Mengapa Webhook Lebih Rentan dari API Biasa
Di API request biasa, Anda yang memulai koneksi ke server eksternal — ada mutual trust yang sudah dibangun lewat API key atau OAuth. Webhook membalik arah ini: server eksternal yang mengirim request ke endpoint Anda. Siapa pun yang tahu URL endpoint Anda bisa mengirim request ke sana.
Tiga ancaman utama yang perlu dijawab:
- Spoofing: attacker mengirim payload palsu yang tampak seperti dari payment provider, memicu kredit saldo atau perubahan status yang tidak sah.
- Replay attack: payload valid yang pernah dikirim provider direkam, lalu dikirim ulang untuk memicu aksi yang sama berkali-kali.
- Duplicate delivery: provider sendiri mengirim event yang sama lebih dari sekali karena timeout atau retry policy mereka — ini bukan serangan, tapi efeknya sama merusaknya.
HMAC signature menjawab ancaman pertama. Timestamp window menjawab ancaman kedua. Idempotency key menjawab ancaman ketiga — dan juga menjadi jaring pengaman terakhir untuk replay attack.
Lapisan 1: Verifikasi HMAC Signature
Provider webhook yang serius (Stripe, Midtrans, GitHub, dll.) menandatangani setiap payload dengan HMAC-SHA256 menggunakan shared secret. Mereka menghitung HMAC(secret, payload) dan menaruhnya di header request — biasanya X-Signature-256, X-Hub-Signature-256, atau nama serupa tergantung provider.
Middleware verifikasi di Express:
import crypto from 'crypto';
import { Request, Response, NextFunction } from 'express';
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET!;
const SIGNATURE_HEADER = 'x-webhook-signature';
export function verifyHmacSignature(
req: Request,
res: Response,
next: NextFunction,
): void {
// Body harus dibaca sebagai raw Buffer — pasang express.raw() sebelum middleware ini
const rawBody = req.body as Buffer;
const receivedSig = req.headers[SIGNATURE_HEADER] as string | undefined;
if (!receivedSig) {
res.status(401).json({ error: 'Missing signature' });
return;
}
const computed = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(rawBody)
.digest();
const received = Buffer.from(receivedSig.replace('sha256=', ''), 'hex');
// WAJIB: timingSafeEqual untuk mencegah timing attack
if (received.length !== computed.length || !crypto.timingSafeEqual(received, computed)) {
res.status(401).json({ error: 'Invalid signature' });
return;
}
next();
}
Dua detail yang sering dilewatkan:
Pertama, body harus dibaca sebagai raw Buffer, bukan string atau object yang sudah di-parse. Kalau Express sudah parse JSON dulu, Anda tidak bisa mendapatkan kembali byte-exact payload aslinya — whitespace yang berbeda atau key ordering yang berubah saat serialisasi ulang akan menghasilkan HMAC yang berbeda meski payload “sama”. Setup route-nya:
// Pasang express.raw() SEBELUM json parser untuk route webhook
app.post(
'/webhooks/payment',
express.raw({ type: 'application/json' }),
verifyHmacSignature,
parseWebhookBody, // parse JSON dari req.body Buffer setelah signature valid
webhookHandler,
);
Kedua, timingSafeEqual bukan opsional. Perbandingan string biasa (=== atau ===) akan short-circuit di karakter pertama yang berbeda — seorang attacker yang mengukur waktu respons secara statistik bisa menebak signature satu karakter per satu karakter. timingSafeEqual memastikan waktu perbandingan selalu konstan terlepas dari seberapa mirip kedua nilai.
Lapisan 2: Timestamp Window untuk Replay Attack
HMAC yang valid tidak berarti request ini baru. Seorang attacker yang berhasil merekam satu request valid bisa mengirimnya ulang satu jam kemudian, satu hari kemudian, atau di-script untuk dikirim tiap menit. Signature tetap valid.
Solusinya: provider menyertakan timestamp di payload atau header, dan kita menolak request yang timestampnya di luar window tertentu.
const REPLAY_WINDOW_SECONDS = 300; // 5 menit
export function verifyTimestamp(
req: Request,
res: Response,
next: NextFunction,
): void {
// Beberapa provider taruh di header, sebagian di body payload
const tsHeader = req.headers['x-webhook-timestamp'] as string | undefined;
if (!tsHeader) {
res.status(400).json({ error: 'Missing timestamp' });
return;
}
const eventTimestamp = parseInt(tsHeader, 10); // Unix epoch detik
const nowSeconds = Math.floor(Date.now() / 1000);
const diff = Math.abs(nowSeconds - eventTimestamp);
if (diff > REPLAY_WINDOW_SECONDS) {
res.status(400).json({ error: 'Request expired' });
return;
}
next();
}
Satu catatan penting: timestamp ini harus ikut di-sign bersama payload. Kalau tidak, attacker bisa memodifikasi timestamp di header tanpa membatalkan signature. Provider yang baik biasanya menyertakan timestamp sebagai bagian dari string yang di-sign, misalnya t=<timestamp>.<payload>. Ikuti persis format yang provider dokumentasikan, jangan asumsi.
Lapisan 3: Idempotency Key di Redis
Timestamp window menolak request yang terlalu lama. Tapi dalam window 5 menit, replay masih bisa terjadi. Dan provider yang legitimate pun kadang mengirim event yang sama dua kali karena retry mereka. Idempotency key adalah jaring pengaman final.
Setiap event webhook memiliki ID unik dari provider (Stripe punya evt_xxx, GitHub punya delivery header, dll.). Simpan ID ini di Redis saat pertama kali diproses.
import { createClient } from 'redis';
const redis = createClient({ url: process.env.REDIS_URL });
const IDEMPOTENCY_TTL_SECONDS = 86400; // 24 jam
export async function checkIdempotency(
req: Request,
res: Response,
next: NextFunction,
): Promise<void> {
const eventId = req.headers['x-webhook-event-id'] as string | undefined;
if (!eventId) {
// Kalau provider tidak kirim event ID, generate dari hash payload
// ini fallback — lebih baik pakai ID eksplisit dari provider
res.status(400).json({ error: 'Missing event ID' });
return;
}
const key = `webhook:processed:${eventId}`;
// SET NX: hanya set kalau key belum ada — operasi atomic
const isNew = await redis.set(key, '1', {
NX: true,
EX: IDEMPOTENCY_TTL_SECONDS,
});
if (isNew === null) {
// Key sudah ada = event sudah diproses sebelumnya
// Kembalikan 200 bukan 409 — provider tidak perlu tahu ini duplikat,
// mereka cuma perlu tahu kita "berhasil" menerima
res.status(200).json({ status: 'already_processed' });
return;
}
next();
}
Mengapa 200 dan bukan error code untuk duplikat? Karena provider akan retry kalau mendapat non-2xx. Membalas 200 untuk duplikat memberi tahu mereka bahwa kita sudah “berhasil” menerima — tidak perlu kirim ulang.
Menyatukan Ketiga Lapisan
// webhook.router.ts
import express from 'express';
import { verifyHmacSignature } from './middleware/hmac';
import { verifyTimestamp } from './middleware/timestamp';
import { checkIdempotency } from './middleware/idempotency';
import { paymentWebhookHandler } from './handlers/payment';
const router = express.Router();
router.post(
'/payment',
express.raw({ type: 'application/json' }), // raw body untuk HMAC
verifyHmacSignature, // 1. autentikasi pengirim
verifyTimestamp, // 2. tolak request kadaluarsa
checkIdempotency, // 3. cegah pemrosesan ganda
(req, _res, next) => {
// Parse JSON dari Buffer setelah semua security check lolos
req.body = JSON.parse((req.body as Buffer).toString('utf-8'));
next();
},
paymentWebhookHandler,
);
export default router;
Urutan middleware di sini bukan kebetulan. HMAC diverifikasi paling awal — tidak perlu memproses timestamp atau cek Redis kalau pengirimnya tidak valid. Timestamp dicek sebelum Redis — tidak perlu query storage kalau request sudah expired. Baru setelah keduanya lolos, kita query Redis untuk idempotency.
Trade-off yang Perlu Diakui
Redis sebagai single point of failure. Kalau Redis down, semua webhook ditolak atau — kalau Anda fallthrough saat error — semua webhook diproses tanpa idempotency check. Di production kami, Redis untuk webhook di-setup sebagai cluster terpisah dari Redis caching aplikasi, dengan sentinel, dan webhook middleware dikonfigurasi untuk fallback ke database check saat Redis tidak available (lebih lambat, tapi tetap aman).
Clock drift. Window timestamp 5 menit mengasumsikan clock server Anda tidak terlalu jauh berbeda dari clock pengirim. NTP sync di server adalah prasyarat yang sering dilupakan. Saya pernah debug satu kasus di mana valid webhook dari Midtrans ditolak karena server VPS klien drift 7 menit dari NTP — tidak ada error di kode, murni masalah clock.
Provider yang tidak support semua fitur ini. Tidak semua provider mengirim event ID yang stable atau timestamp yang bisa dipercaya. Untuk provider seperti ini, fallback ke hash dari payload itu sendiri sebagai idempotency key — lebih rapuh (payload yang identik dari dua event berbeda akan dianggap sama), tapi lebih baik dari tidak ada sama sekali.
Verdict
Tiga lapisan ini adalah minimum viable security untuk webhook yang menerima aksi finansial atau mutasi data penting. Kalau Anda hanya punya waktu untuk implementasi satu, pilih idempotency key — ia mencegah kerusakan paling nyata (double processing). Kalau bisa dua, tambah HMAC. Kalau bisa ketiga, implementasikan semuanya dan pasang dalam urutan yang benar.
Endpoint webhook tanpa ketiga lapisan ini adalah permukaan serangan yang terbuka. Di production Jakarta dengan volume transaksi yang tidak kecil, tidak ada ruang untuk “nanti-nanti” soal ini.
Ditulis oleh Reza Pradipta