Webhook sering dianggap sekadar endpoint HTTP yang menerima event dari pihak ketiga. Padahal, dari sudut pandang keamanan dan reliabilitas, webhook adalah input tidak tepercaya dari luar sistem. Jika payload diproses terlalu cepat, diverifikasi dengan cara yang salah, atau tidak dirancang untuk retry dan deduplikasi, Anda bisa mendapat dua masalah sekaligus: kompromi keamanan dan inkonsistensi data.

Risiko ini nyata pada integrasi pihak ketiga dan rantai distribusi. Kasus skrip bash yang diobfusksi lalu dikirim melalui jalur distribusi menunjukkan pelajaran penting: jangan mengasumsikan sumber eksternal selalu aman hanya karena datang dari vendor, CDN, atau mitra resmi. Dalam konteks webhook, prinsip yang sama berlaku: payload harus diperlakukan sebagai data mentah yang belum tepercaya sampai lolos verifikasi signature, pemeriksaan timestamp, dan kontrol replay. Artikel ini fokus pada praktik engineering untuk membangun webhook aman yang juga tahan gagal.

Model ancaman untuk endpoint webhook

Sebelum membahas implementasi, tentukan dulu ancaman yang relevan. Pada umumnya, endpoint webhook menghadapi beberapa risiko berikut:

  • Payload dipalsukan: penyerang mengirim request yang tampak seperti berasal dari penyedia webhook.
  • Replay attack: request valid lama dikirim ulang untuk memicu aksi berulang.
  • Duplikasi event: penyedia webhook mengirim event yang sama beberapa kali, baik karena retry otomatis maupun masalah jaringan.
  • Out-of-order delivery: event datang tidak sesuai urutan pembuatan.
  • Payload berbahaya: isi payload mengandung data tak terduga, ukuran besar, field menyesatkan, atau konten yang memicu bug parser/logging.
  • Kegagalan downstream: database, queue, atau layanan internal sedang lambat atau tidak tersedia.

Tujuan desain webhook bukan hanya menerima request, tetapi memastikan request yang diterima asli, masih relevan, hanya diproses sekali secara efektif, dan dapat diaudit.

Prinsip dasar desain webhook aman

1. Verifikasi sebelum memproses

Kesalahan yang sangat umum adalah langsung melakukan parsing, validasi bisnis, atau bahkan mengeksekusi aksi sebelum signature diverifikasi. Ini berbahaya karena:

  • payload bisa dipalsukan,
  • beberapa parser mengubah representasi data sehingga signature tidak lagi cocok,
  • logic bisnis bisa terpicu oleh request palsu.

Urutan yang lebih aman adalah:

  1. ambil raw request body apa adanya,
  2. baca header signature dan timestamp,
  3. verifikasi signature terhadap raw body,
  4. cek jendela waktu untuk replay protection,
  5. baru lakukan parsing JSON dan pemrosesan lanjutan.

2. Simpan raw payload untuk audit

Raw payload penting untuk:

  • forensik insiden,
  • debug signature mismatch,
  • replay internal saat pengujian,
  • pembuktian event yang diterima saat sengketa integrasi.

Simpan raw payload secara aman, idealnya bersama metadata seperti waktu terima, header penting, hasil verifikasi, dan identitas sumber. Jika payload mengandung data sensitif, terapkan enkripsi saat disimpan dan kebijakan retensi yang jelas.

Verifikasi signature/HMAC yang benar

Pola paling umum untuk webhook aman adalah HMAC. Penyedia dan konsumen berbagi secret yang sama. Penyedia menghitung signature dari body mentah, lalu konsumen menghitung ulang dan membandingkannya.

Mengapa harus memakai raw body

HMAC harus dihitung dari representasi byte yang sama persis. Jika Anda mem-parse JSON lalu men-serialize ulang, urutan key, whitespace, dan encoding bisa berubah. Akibatnya, signature valid pun akan dianggap salah.

Contoh alur header

Format header berbeda-beda antar penyedia, tetapi umumnya mengandung:

  • X-Signature atau sejenisnya,
  • X-Timestamp,
  • kadang X-Event-Id.

Jangan mengunci implementasi pada nama header tertentu jika sistem Anda harus menerima beberapa vendor. Buat adapter per penyedia.

Contoh verifikasi HMAC di pseudocode

raw_body = read_request_body_bytes()
received_sig = request.header("X-Signature")
timestamp = request.header("X-Timestamp")

if missing(received_sig) or missing(timestamp):
    return 400

if abs(now_utc() - parse_timestamp(timestamp)) > 300 seconds:
    return 401

signed_payload = timestamp + "." + raw_body
expected_sig = hmac_sha256_hex(secret, signed_payload)

if !constant_time_compare(received_sig, expected_sig):
    return 401

payload = parse_json(raw_body)
process(payload)

Poin penting implementasi

  • Gunakan perbandingan constant-time untuk mencegah timing attack pada pembandingan signature.
  • Gabungkan timestamp ke material yang ditandatangani jika penyedia mendukungnya. Ini membantu mencegah replay.
  • Secret per integrasi lebih baik daripada satu secret global. Jika satu secret bocor, dampaknya terbatas.
  • Rotasi secret sebaiknya didukung. Pada masa transisi, verifikasi bisa menerima secret aktif dan secret sebelumnya.

Contoh implementasi sederhana dengan Node.js

const crypto = require('crypto');

function verifyWebhook({ rawBody, signature, timestamp, secret, now = Date.now() }) {
  if (!signature || !timestamp) return { ok: false, reason: 'missing_headers' };

  const tsMillis = Number(timestamp) * 1000;
  if (!Number.isFinite(tsMillis)) return { ok: false, reason: 'invalid_timestamp' };

  const age = Math.abs(now - tsMillis);
  const maxAge = 5 * 60 * 1000;
  if (age > maxAge) return { ok: false, reason: 'timestamp_out_of_window' };

  const signedPayload = `${timestamp}.${rawBody}`;
  const expected = crypto
    .createHmac('sha256', secret)
    .update(signedPayload, 'utf8')
    .digest('hex');

  const a = Buffer.from(signature, 'utf8');
  const b = Buffer.from(expected, 'utf8');
  if (a.length !== b.length) return { ok: false, reason: 'signature_mismatch' };

  const ok = crypto.timingSafeEqual(a, b);
  return { ok, reason: ok ? null : 'signature_mismatch' };
}

Catatan: contoh di atas memakai format timestamp.raw_body sebagai ilustrasi. Selalu ikuti spesifikasi penyedia webhook Anda untuk string yang ditandatangani dan format encoding signature.

Timestamp dan replay protection

Signature saja belum cukup. Jika request valid dapat direkam lalu dikirim ulang, sistem Anda masih bisa tertipu. Karena itu, webhook aman perlu replay protection.

Jendela waktu

Strategi paling umum adalah menolak request dengan timestamp di luar jendela tertentu, misalnya 5 menit. Trade-off-nya:

  • Jendela terlalu sempit: request sah bisa ditolak jika ada skew jam atau antrean jaringan.
  • Jendela terlalu longgar: replay attack lebih mudah dilakukan.

Karena itu, sinkronisasi waktu server dengan NTP penting.

Deduplikasi request replay

Jika penyedia mengirim event ID unik, simpan ID tersebut dalam penyimpanan cepat seperti Redis atau database dengan TTL. Jika request dengan ID sama datang lagi dalam jendela tertentu, tandai sebagai duplikat. Ini berguna bahkan jika timestamp masih valid.

key = "webhook:event:" + provider + ":" + event_id
if redis.setnx(key, now):
    redis.expire(key, 86400)
    continue_processing()
else:
    return duplicate_ack()

Jika penyedia tidak menyediakan event ID, Anda bisa membuat fingerprint dari kombinasi sumber, timestamp, dan hash raw payload. Ini tidak seideal event ID resmi, tetapi lebih baik daripada tanpa deduplikasi.

Allowlist sumber: berguna, tetapi jangan dijadikan satu-satunya kontrol

IP allowlist atau pembatasan jaringan bisa membantu, tetapi sering disalahpahami. Pada integrasi modern, penyedia webhook bisa memakai banyak alamat IP, infrastruktur dinamis, CDN, atau rentang yang berubah. Karena itu:

  • Jangan mengganti signature verification dengan IP allowlist.
  • Gunakan allowlist sebagai lapisan tambahan jika penyedia menerbitkan rentang IP yang stabil dan dapat dipantau.
  • Audit header proxy dengan hati-hati; jangan percaya begitu saja pada X-Forwarded-For jika tidak dikontrol oleh reverse proxy Anda sendiri.

Allowlist yang realistis biasanya diterapkan di level WAF, load balancer, API gateway, atau reverse proxy, sambil tetap mempertahankan HMAC di level aplikasi.

Catatan: Banyak insiden terjadi bukan karena signature tidak ada, melainkan karena tim merasa jaringan internal, CDN, atau partner sudah cukup dipercaya. Anggapan ini berbahaya ketika rantai distribusi berubah, konfigurasi proxy salah, atau kredensial partner bocor.

Idempotensi dan deduplikasi event

Dalam sistem nyata, webhook hampir pasti akan dikirim ulang. Anda harus mengasumsikan model pengiriman at least once, bukan exactly once. Artinya, event yang sama bisa diterima lebih dari sekali.

Apa itu idempotensi

Operasi idempoten berarti menjalankan operasi yang sama berkali-kali menghasilkan efek akhir yang sama. Contoh:

  • mengubah status pesanan menjadi paid berdasarkan event tertentu bisa dibuat idempoten,
  • menambahkan saldo tanpa pengecekan idempotensi tidak aman karena duplikasi akan menggandakan efek.

Strategi praktis

  1. Simpan event ID unik dari penyedia sebagai kunci idempotensi.
  2. Buat constraint unik di database pada kombinasi yang relevan, misalnya provider + event_id.
  3. Pisahkan penerimaan event dari pemrosesan bisnis: endpoint hanya memverifikasi, menyimpan, dan mengantrekan job.
  4. Pastikan consumer queue juga idempoten. Jangan berhenti di layer HTTP saja.

Contoh skema tabel

CREATE TABLE webhook_events (
  id BIGSERIAL PRIMARY KEY,
  provider TEXT NOT NULL,
  event_id TEXT NOT NULL,
  received_at TIMESTAMP NOT NULL,
  signature_valid BOOLEAN NOT NULL,
  payload_hash TEXT NOT NULL,
  raw_payload BYTEA NOT NULL,
  status TEXT NOT NULL,
  UNIQUE (provider, event_id)
);

Dengan constraint unik, dua request identik yang masuk hampir bersamaan tidak akan sama-sama diproses sebagai event baru. Salah satunya akan gagal insert atau ditandai sebagai duplikat.

Deduplikasi event vs idempotensi bisnis

Keduanya terkait, tetapi tidak sama:

  • Deduplikasi event mencegah event yang sama diproses dua kali.
  • Idempotensi bisnis memastikan efek bisnis tetap benar meski deduplikasi gagal atau event berbeda memicu state yang sama.

Desain yang kuat biasanya membutuhkan keduanya.

Retry, backoff, dan status code yang tepat

Jangan memproses terlalu lama di request thread

Jika endpoint webhook melakukan kerja berat secara sinkron, timeout akan meningkat dan penyedia kemungkinan melakukan retry. Ini memperbesar duplikasi dan beban sistem. Pola yang lebih aman:

  1. verifikasi request,
  2. simpan event secara atomik,
  3. masukkan ke queue,
  4. kembalikan respons cepat.

Status code yang umum dipakai

  • 2xx: event diterima. Gunakan ketika request valid dan sudah tercatat, meski pemrosesan lanjutan dilakukan asinkron.
  • 400: request buruk, misalnya header wajib tidak ada atau format tidak valid.
  • 401/403: signature tidak valid atau sumber tidak diizinkan.
  • 409: bisa dipakai untuk konflik tertentu, tetapi banyak integrasi lebih aman mengembalikan 2xx untuk duplikat yang sudah diketahui agar penyedia tidak terus retry.
  • 429: rate limit; gunakan hati-hati karena beberapa penyedia akan retry agresif.
  • 5xx: hanya jika Anda benar-benar gagal menerima atau menyimpan event. Ini biasanya memicu retry dari penyedia.

Prinsip praktisnya: jika event valid dan sudah disimpan aman untuk diproses nanti, balas 2xx secepat mungkin.

Retry internal dengan backoff

Selain retry dari penyedia, sistem Anda juga perlu retry internal saat worker gagal memproses event karena ketergantungan downstream sedang bermasalah. Gunakan:

  • exponential backoff,
  • jitter agar retry tidak menumpuk serentak,
  • dead-letter queue untuk kasus gagal permanen atau melewati batas retry.
attempt 1: delay 5s
attempt 2: delay 15s
attempt 3: delay 45s
attempt 4: delay 2m + jitter
attempt 5: dead-letter / manual review

Jangan melakukan retry tanpa batas untuk error yang jelas permanen, misalnya payload tidak memenuhi kontrak bisnis atau referensi resource tidak pernah ada.

Contoh alur implementasi end-to-end

Berikut alur yang cukup aman dan operasional untuk endpoint webhook produksi:

  1. Request masuk melalui reverse proxy/API gateway.
  2. Gateway menerapkan batas ukuran body, TLS, dan opsional IP allowlist.
  3. Aplikasi membaca raw body dan header terkait.
  4. Aplikasi memverifikasi timestamp dan signature HMAC.
  5. Aplikasi menghitung payload_hash untuk kebutuhan audit/deduplikasi tambahan.
  6. Aplikasi menyimpan record event ke database secara atomik dengan constraint unik pada provider + event_id.
  7. Jika event baru, aplikasi menerbitkan job ke queue.
  8. Aplikasi mengembalikan 202 Accepted atau 200 OK.
  9. Worker queue memproses event secara idempoten.
  10. Jika worker gagal sementara, job di-retry dengan backoff dan jitter.
  11. Jika gagal permanen, event masuk dead-letter queue dan memicu alert.

Contoh pseudocode endpoint

raw_body = read_request_body_bytes()
headers = read_headers()

verify = verify_signature(raw_body, headers, secret)
if !verify.ok:
    audit_log(status="rejected", reason=verify.reason, raw_body=raw_body, headers=headers)
    return 401

payload = parse_json(raw_body)
event_id = extract_event_id(payload, headers)
provider = "partner_a"
payload_hash = sha256(raw_body)

begin transaction
  inserted = insert_webhook_event_if_new(
    provider=provider,
    event_id=event_id,
    raw_payload=raw_body,
    payload_hash=payload_hash,
    signature_valid=true,
    status="received"
  )

  if inserted:
    enqueue_job("process_webhook_event", { provider, event_id })
commit

return 202

Pola ini bekerja karena endpoint hanya bertanggung jawab untuk menerima dengan aman, bukan menyelesaikan semua proses bisnis saat itu juga.

Observabilitas dan audit

Webhook yang aman juga harus mudah di-debug. Tanpa observabilitas, Anda akan sulit membedakan apakah kegagalan terjadi karena signature salah, jam server bergeser, queue macet, atau downstream timeout.

Data yang sebaiknya dicatat

  • provider dan endpoint yang dipanggil,
  • event_id,
  • request_id/correlation_id,
  • hasil verifikasi signature,
  • selisih timestamp,
  • ukuran payload,
  • status pemrosesan: received, duplicate, queued, processed, failed, dead-letter,
  • jumlah retry dan alasan error.

Hindari menulis secret, token, atau payload sensitif mentah ke log aplikasi umum. Jika perlu menyimpan raw payload untuk audit, pisahkan dari log operasional dan batasi aksesnya.

Metrik yang berguna

  • jumlah webhook masuk per provider,
  • persentase signature mismatch,
  • rasio duplikasi event,
  • latensi dari diterima sampai diproses,
  • jumlah retry worker,
  • ukuran dead-letter queue.

Alarm sederhana tetapi efektif: lonjakan 401 signature mismatch, kenaikan tajam duplikasi, atau backlog queue yang tidak turun.

Jebakan umum yang sering terjadi

Memproses payload sebelum verifikasi

Ini kesalahan paling kritis. Bahkan validasi schema yang tampak tidak berbahaya tetap sebaiknya dilakukan setelah verifikasi, kecuali kontrol dasar seperti batas ukuran request.

Menggunakan body yang sudah diubah middleware

Beberapa framework otomatis mem-parse JSON dan membuang raw body asli. Jika Anda menghitung HMAC dari object hasil parsing, verifikasi bisa gagal. Pastikan ada cara mengambil body mentah sebelum middleware mengubahnya.

Menganggap TLS saja cukup

TLS melindungi transport, tetapi tidak membuktikan bahwa payload berasal dari penyedia webhook yang benar di level aplikasi. Signature tetap diperlukan.

Tidak menyiapkan idempotensi di worker

Meski endpoint sudah mendeduplikasi, worker masih bisa retry akibat crash setelah sebagian efek bisnis terjadi. Pastikan operasi downstream aman dijalankan ulang.

Mengembalikan 5xx untuk event duplikat

Jika event yang sama sudah pernah diterima dan dicatat, mengembalikan 2xx biasanya lebih baik agar penyedia berhenti retry. Mengirim 5xx hanya menambah beban dan kebisingan.

Tidak membatasi ukuran body

Payload besar bisa menghabiskan memori, memperlambat hashing, atau memicu masalah parser. Terapkan batas ukuran yang sesuai kontrak integrasi.

Checklist praktis untuk webhook aman

  • Ambil dan verifikasi raw request body.
  • Gunakan HMAC/signature dengan pembandingan constant-time.
  • Validasi timestamp dan terapkan replay protection.
  • Gunakan event ID atau fingerprint untuk deduplikasi.
  • Terapkan idempotency key dan constraint unik di penyimpanan.
  • Simpan raw payload dan metadata penting untuk audit.
  • Balas 2xx cepat setelah event valid tersimpan.
  • Proses lanjutan melalui queue dengan retry, backoff, dan jitter.
  • Pantau metrik, log terstruktur, dan dead-letter queue.
  • Jangan percaya pada vendor, CDN, atau jaringan saja; perlakukan semua payload sebagai tak tepercaya sampai terbukti valid.

Penutup

Membangun webhook aman bukan hanya soal menambahkan satu header signature. Anda perlu memikirkan seluruh siklus hidup event: autentikasi payload, replay protection, penyimpanan audit, deduplikasi, idempotensi, retry, dan observabilitas. Dengan desain seperti ini, endpoint tetap aman terhadap payload tak tepercaya dan tetap tahan terhadap kegagalan jaringan maupun duplikasi pengiriman.

Jika Anda harus memilih prioritas implementasi, urutannya sederhana: verifikasi raw payload lebih dulu, simpan event secara atomik, balas cepat, lalu proses asinkron secara idempoten. Pola ini menyelesaikan sebagian besar masalah keamanan dan operasional pada webhook produksi.