karawaci.kode

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:

  1. Spoofing: attacker mengirim payload palsu yang tampak seperti dari payment provider, memicu kredit saldo atau perubahan status yang tidak sah.
  2. Replay attack: payload valid yang pernah dikirim provider direkam, lalu dikirim ulang untuk memicu aksi yang sama berkali-kali.
  3. 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