Validasi signature webhook tidak cukup hanya dengan menghitung HMAC dan membandingkannya dengan header. Implementasi yang aman harus memperhatikan timestamp, kemungkinan replay attack, bentuk payload yang benar-benar ditandatangani, serta toleransi clock drift antara sistem Anda dan penyedia webhook.

Masalah paling umum di lapangan biasanya bukan algoritma kriptografinya, melainkan detail kontrak request: body parser mengubah payload mentah, timestamp diverifikasi terlalu lambat, canonical string salah, atau aplikasi menerima request lama yang diputar ulang. Jika salah satu bagian ini keliru, hasilnya bisa dua macam: false negative pada webhook yang sah, atau lebih buruk, webhook palsu yang lolos verifikasi.

Apa yang sebenarnya divalidasi pada webhook?

Secara umum, penyedia webhook mengirim request HTTP yang berisi:

  • Payload: body JSON atau format lain.
  • Header signature: hasil tanda tangan atas payload, atau atas gabungan timestamp dan payload.
  • Timestamp: waktu ketika signature dibuat.
  • Identifier tambahan: kadang ada event id, versi signature, atau nama algoritma.

Tujuan verifikasi adalah memastikan tiga hal:

  1. Request benar-benar dibuat oleh pihak yang mengetahui shared secret.
  2. Payload tidak diubah di tengah jalan.
  3. Request masih berada dalam jendela waktu yang masuk akal dan bukan hasil replay.

Pada banyak provider, signature dihitung dengan HMAC menggunakan secret yang dibagikan saat konfigurasi webhook. Namun kontraknya tidak selalu sama. Ada provider yang menandatangani raw body, ada yang menandatangani timestamp + "." + raw_body, ada yang mendukung beberapa signature dalam satu header untuk rotasi secret, dan ada yang menggunakan format header yang lebih kompleks.

Intinya: jangan mengasumsikan semua provider memakai format yang sama. Ikuti dokumentasi provider secara literal untuk string yang harus ditandatangani, algoritma, encoding, dan format header.

Kontrak request yang harus dipahami

1. Header signature

Header signature biasanya berisi salah satu dari pola berikut:

  • Satu nilai digest, misalnya heksadesimal HMAC-SHA256.
  • Beberapa pasangan nilai, misalnya versi signature dan timestamp dalam satu header.
  • Lebih dari satu signature untuk mendukung rotasi secret.

Kesalahan umum adalah memperlakukan header sebagai string bebas lalu memotongnya secara naif. Jika provider menggunakan format seperti t=...,v1=...,v1=..., parser Anda harus tahan terhadap urutan pasangan yang berbeda, spasi, dan kemunculan beberapa signature dengan versi sama.

2. Timestamp

Timestamp dipakai untuk membatasi masa berlaku request. Tanpa pemeriksaan timestamp, penyerang yang berhasil menangkap satu request valid dapat mengirim ulang request tersebut kapan saja selama signature masih sama.

Timestamp biasanya diverifikasi dengan membandingkan selisih waktu antara waktu lokal server dan timestamp dari request. Jika selisih melebihi toleransi, request ditolak.

3. Canonical payload

Bagian paling rawan adalah apa tepatnya yang ditandatangani. Dalam banyak kasus, yang ditandatangani adalah payload mentah persis byte-per-byte, bukan hasil parsing JSON lalu di-serialize ulang.

Contoh perubahan yang tampak sepele tetapi dapat merusak verifikasi:

  • Perubahan urutan key JSON.
  • Normalisasi spasi atau newline.
  • Konversi encoding.
  • Body parser yang mengubah angka, boolean, atau unicode escape.
  • Framework yang membentuk ulang JSON sebelum verifikasi.

Jika provider mendefinisikan canonical string sebagai timestamp + "." + raw_body, maka Anda harus menggunakan body mentah yang diterima dari socket HTTP, bukan objek JSON hasil parse.

4. Toleransi clock drift

Clock drift adalah selisih waktu antara server Anda dan sistem provider. Jika toleransi terlalu ketat, webhook sah bisa ditolak. Jika terlalu longgar, jendela replay menjadi lebih lebar.

Praktiknya, toleransi ditentukan oleh dokumentasi provider atau kebijakan keamanan internal. Nilai pastinya bergantung pada kebutuhan sistem, tetapi prinsipnya sama:

  • Terlalu kecil: lebih aman terhadap replay, tetapi rentan false negative.
  • Terlalu besar: lebih toleran terhadap drift, tetapi memperbesar peluang replay.

Karena itu, sinkronisasi waktu server melalui NTP atau mekanisme sejenis adalah syarat dasar. Validasi timestamp yang baik tidak akan membantu jika jam sistem Anda meleset cukup jauh.

Risiko replay attack dan mengapa timestamp saja belum selalu cukup

Replay attack terjadi ketika request webhook yang sah dikirim ulang tanpa modifikasi. Karena signature masih valid terhadap payload yang sama, verifikasi HMAC saja tidak akan mendeteksinya.

Timestamp membantu mempersempit jendela replay, tetapi ada dua catatan penting:

  1. Jika request diputar ulang masih dalam jendela toleransi, signature bisa tetap lolos.
  2. Jika provider tidak mengirim identifier unik per event, Anda mungkin tidak bisa membedakan event asli dan replay cepat hanya dari signature.

Karena itu, pertahanan yang lebih baik biasanya menggabungkan:

  • Validasi timestamp untuk membatasi usia request.
  • Penyimpanan event id atau delivery id untuk mencegah pemrosesan ganda.
  • Idempotency pada layer bisnis agar efek samping tidak terjadi dua kali.

Jika provider mengirim event ID unik, simpan ID tersebut dengan TTL yang sesuai atau tandai sebagai sudah diproses. Jika provider tidak menyediakan ID, Anda bisa menyimpan fingerprint dari kombinasi yang stabil, misalnya timestamp dan digest tertentu, tetapi pendekatan ini lebih rapuh dibanding ID resmi dari provider.

Urutan validasi yang benar

Urutan verifikasi sangat penting. Jangan langsung mem-parse body dan menjalankan logika bisnis sebelum request dinyatakan sah.

  1. Ambil raw request body sebelum ada modifikasi.
  2. Ambil header yang diperlukan: signature, timestamp, delivery id bila ada.
  3. Validasi keberadaan header wajib.
  4. Parse format header signature sesuai kontrak provider.
  5. Periksa timestamp terhadap toleransi drift.
  6. Bentuk canonical string persis seperti yang diharapkan provider.
  7. Hitung expected signature dengan secret yang benar.
  8. Bandingkan dengan constant-time comparison.
  9. Cek replay/deduplication menggunakan event id atau delivery id.
  10. Baru parse payload dan lanjutkan ke pemrosesan bisnis.

Alasan timestamp diperiksa lebih awal adalah efisiensi dan pengurangan risiko replay. Alasan body mentah diambil paling awal adalah agar payload yang diverifikasi sama dengan payload yang dikirim.

Contoh alur verifikasi yang aman

Pseudocode umum

function verifyWebhook(request, secret, toleranceSeconds, replayStore) {
  rawBody = request.getRawBody()
  signatureHeader = request.getHeader("X-Signature")
  timestampHeader = request.getHeader("X-Timestamp")
  deliveryId = request.getHeader("X-Delivery-Id")

  if !signatureHeader or !timestampHeader {
    return reject(400, "missing required headers")
  }

  timestamp = parseInteger(timestampHeader)
  if timestamp is invalid {
    return reject(400, "invalid timestamp")
  }

  now = currentUnixTime()
  age = abs(now - timestamp)
  if age > toleranceSeconds {
    return reject(401, "timestamp outside tolerance")
  }

  canonical = timestampHeader + "." + rawBody
  expected = HMAC_SHA256(secret, canonical)

  signatures = parseSignatureHeader(signatureHeader)
  if !constantTimeMatchAny(expected, signatures) {
    return reject(401, "invalid signature")
  }

  if deliveryId {
    if replayStore.exists(deliveryId) {
      return reject(409, "replay detected")
    }
    replayStore.put(deliveryId, ttl=toleranceSeconds)
  }

  payload = parseJson(rawBody)
  return accept(payload)
}

Poin penting dari pseudocode di atas:

  • getRawBody() harus benar-benar mengembalikan body asli.
  • constantTimeMatchAny dipakai agar pembandingan signature tidak bocor secara timing.
  • Penyimpanan replay sebaiknya dilakukan hanya setelah signature valid.
  • TTL replay store biasanya mengikuti atau sedikit melebihi jendela toleransi dan kebutuhan deduplikasi bisnis.

Contoh backend sederhana dengan Node.js HTTP

Contoh berikut sengaja memakai modul bawaan agar fokus pada prinsip verifikasi, bukan perilaku framework tertentu.

const http = require('http');
const crypto = require('crypto');

const SECRET = process.env.WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 300;
const replayCache = new Map();

function timingSafeEqualHex(a, b) {
  const aBuf = Buffer.from(a, 'hex');
  const bBuf = Buffer.from(b, 'hex');
  if (aBuf.length !== bBuf.length) return false;
  return crypto.timingSafeEqual(aBuf, bBuf);
}

function hmacHex(secret, data) {
  return crypto.createHmac('sha256', secret).update(data).digest('hex');
}

function cleanupReplayCache() {
  const now = Date.now();
  for (const [key, expiresAt] of replayCache.entries()) {
    if (expiresAt <= now) replayCache.delete(key);
  }
}

http.createServer((req, res) => {
  if (req.method !== 'POST' || req.url !== '/webhook') {
    res.statusCode = 404;
    return res.end('not found');
  }

  const chunks = [];
  req.on('data', chunk => chunks.push(chunk));
  req.on('end', () => {
    cleanupReplayCache();

    const rawBodyBuffer = Buffer.concat(chunks);
    const rawBody = rawBodyBuffer.toString('utf8');

    const signature = req.headers['x-signature'];
    const timestampHeader = req.headers['x-timestamp'];
    const deliveryId = req.headers['x-delivery-id'];

    if (!signature || !timestampHeader) {
      res.statusCode = 400;
      return res.end('missing required headers');
    }

    const timestamp = Number(timestampHeader);
    if (!Number.isInteger(timestamp)) {
      res.statusCode = 400;
      return res.end('invalid timestamp');
    }

    const now = Math.floor(Date.now() / 1000);
    if (Math.abs(now - timestamp) > TOLERANCE_SECONDS) {
      res.statusCode = 401;
      return res.end('timestamp outside tolerance');
    }

    const canonical = `${timestampHeader}.${rawBody}`;
    const expected = hmacHex(SECRET, canonical);

    if (!timingSafeEqualHex(expected, signature)) {
      res.statusCode = 401;
      return res.end('invalid signature');
    }

    if (deliveryId) {
      if (replayCache.has(deliveryId)) {
        res.statusCode = 409;
        return res.end('replay detected');
      }
      replayCache.set(deliveryId, Date.now() + TOLERANCE_SECONDS * 1000);
    }

    let payload;
    try {
      payload = JSON.parse(rawBody);
    } catch {
      res.statusCode = 400;
      return res.end('invalid json');
    }

    // Proses bisnis di sini
    res.statusCode = 200;
    res.end('ok');
  });
}).listen(3000);

Contoh ini tidak ditujukan sebagai implementasi produksi final. Di produksi, replay cache sebaiknya memakai penyimpanan terpusat seperti Redis jika ada banyak instance aplikasi. Tanpa itu, request replay bisa lolos ketika masuk ke instance yang berbeda.

Perbedaan HMAC per-provider yang sering menjebak

Meskipun banyak provider memakai HMAC, detail berikut sering berbeda:

  • Algoritma: misalnya SHA-256, tetapi jangan diasumsikan selalu begitu.
  • Data yang ditandatangani: raw body saja, atau timestamp + separator + body.
  • Encoding hasil digest: hex, base64, atau bentuk lain.
  • Format header: satu nilai langsung, atau daftar pasangan key-value.
  • Rotasi secret: bisa ada lebih dari satu signature yang valid selama masa transisi.
  • Header timestamp: terpisah atau digabung dalam header signature.

Akibatnya, utilitas verifikasi yang terlalu generik sering gagal. Pendekatan yang lebih aman adalah membuat adapter per provider dengan kontrak yang eksplisit:

interface WebhookVerifier {
  parseHeaders(request)
  buildCanonicalString(rawBody, parsedHeaders)
  computeSignature(secret, canonical)
  verify(request, rawBody)
}

Dengan cara ini, Anda tidak mencampur aturan provider A dan provider B dalam satu blok kode penuh if yang sulit diaudit.

Masalah body parsing yang mengubah payload mentah

Ini adalah penyebab false negative yang sangat sering. Banyak framework secara otomatis membaca dan mem-parse body request sebelum handler Anda berjalan. Jika Anda menghitung HMAC dari objek JSON yang sudah diparse lalu diubah kembali menjadi string, hasilnya bisa berbeda dari payload mentah asli.

Contoh perbedaan yang merusak signature:

  • Input asli: {"a":1, "b":2}
  • Hasil stringify framework: {"a":1,"b":2}

Secara semantik sama, tetapi secara byte berbeda. Signature atas string pertama tidak akan cocok dengan string kedua.

Prinsip implementasinya:

  1. Simpan body mentah dulu.
  2. Verifikasi signature menggunakan body mentah.
  3. Setelah valid, baru parse JSON.

Di beberapa framework, ini berarti Anda harus menonaktifkan parser default untuk route webhook atau menggunakan hook yang menyimpan raw body sebelum parsing. Detail implementasinya tergantung framework, jadi pastikan Anda mengecek mekanisme resmi untuk mengakses payload mentah.

Constant-time comparison dan kenapa ini penting

Membandingkan signature dengan operator string biasa dapat membuka peluang serangan timing pada kondisi tertentu, karena durasi perbandingan bisa berbeda tergantung posisi karakter pertama yang tidak cocok. Risiko praktisnya bergantung pada konteks, tetapi karena library standar umumnya sudah menyediakan fungsi aman, gunakan constant-time comparison untuk membandingkan digest.

Beberapa hal yang perlu diperhatikan:

  • Biasanya panjang buffer harus sama sebelum memakai fungsi timing-safe.
  • Bandingkan bentuk biner yang konsisten jika memungkinkan.
  • Jangan melakukan normalisasi yang tidak perlu terhadap digest.

Logging, audit, dan observability

Validasi webhook yang aman juga harus dapat di-debug. Namun logging yang berlebihan bisa membocorkan rahasia. Tujuannya adalah mencatat cukup informasi untuk audit tanpa menyimpan secret atau payload sensitif secara sembarangan.

Apa yang sebaiknya dicatat

  • Waktu penerimaan request.
  • Provider atau endpoint webhook.
  • Delivery ID atau event ID jika ada.
  • Hasil verifikasi: sukses/gagal.
  • Alasan gagal: header hilang, timestamp di luar toleransi, signature tidak cocok, replay terdeteksi.
  • Selisih waktu timestamp terhadap server.
  • Status pemrosesan bisnis setelah verifikasi.

Apa yang sebaiknya tidak dicatat mentah-mentah

  • Shared secret.
  • Full signature jika tidak diperlukan.
  • Payload sensitif lengkap tanpa kebijakan retensi yang jelas.
  • Header otorisasi lain yang tidak relevan.

Jika Anda perlu korelasi untuk investigasi, lebih aman mencatat:

  • Hash dari raw body untuk identifikasi.
  • Potongan awal delivery ID.
  • Ringkasan metadata non-sensitif.

Tip operasional: buat metrik terpisah untuk signature_invalid, timestamp_expired, replay_detected, dan processing_failed. Ini membantu membedakan masalah keamanan dari bug aplikasi.

Kesalahan implementasi yang paling umum

  • Memverifikasi JSON hasil parsing, bukan raw body.
  • Tidak memeriksa timestamp, sehingga replay lama tetap valid.
  • Menggunakan toleransi terlalu besar tanpa alasan jelas.
  • Tidak sinkronisasi waktu server, sehingga webhook sah ditolak.
  • Mengabaikan replay/deduplication saat provider mengirim event yang bisa diputar ulang.
  • Membandingkan signature dengan string compare biasa.
  • Mencampur aturan beberapa provider dalam satu fungsi yang tidak eksplisit.
  • Menyimpan secret atau full payload sensitif di log.
  • Menerima request sebelum verifikasi selesai, misalnya enqueue job bisnis terlebih dahulu.
  • Tidak mendukung rotasi secret, padahal provider dapat mengirim signature dengan secret lama dan baru selama transisi.

Strategi produksi yang lebih kuat

1. Dukungan rotasi secret

Saat secret diganti, bisa ada periode ketika webhook yang sah ditandatangani dengan secret lama atau baru. Verifier sebaiknya mendukung beberapa secret aktif sementara:

  1. Coba verifikasi dengan secret utama.
  2. Jika gagal, coba secret cadangan yang masih dalam masa transisi.
  3. Catat secret mana yang cocok untuk audit, tanpa menulis nilainya.

2. Replay store terpusat

Pada sistem multi-instance, replay detection harus memakai penyimpanan bersama seperti cache terdistribusi atau database cepat. Jika hanya memakai memori lokal, deduplikasi tidak konsisten antar instance.

3. Idempotent processing

Walau replay sudah dicegah, Anda tetap perlu membuat operasi bisnis se-idempotent mungkin. Provider webhook sering mengulang pengiriman karena timeout atau kegagalan jaringan. Sistem Anda harus mampu menerima event yang sama tanpa efek samping ganda.

4. Respons cepat, proses async bila perlu

Setelah verifikasi berhasil dan event dicatat, sering kali lebih baik mengakui request dengan cepat lalu memproses pekerjaan berat secara asynchronous. Ini mengurangi retry yang tidak perlu dari provider. Namun pastikan pencatatan event dan status verifikasinya terjadi sebelum respons sukses dikirim.

Checklist validasi signature webhook untuk produksi

  • Raw body tersedia dan dipakai untuk verifikasi.
  • Kontrak canonical string mengikuti dokumentasi provider secara tepat.
  • Header signature dan timestamp diparse dengan benar.
  • Timestamp diverifikasi dengan toleransi drift yang jelas.
  • Jam server tersinkronisasi.
  • Signature dibandingkan dengan constant-time comparison.
  • Replay detection memakai delivery ID atau event ID jika tersedia.
  • Deduplikasi bekerja lintas instance aplikasi.
  • Rotasi secret didukung.
  • Payload baru diparse setelah verifikasi sukses.
  • Logging tidak membocorkan secret atau data sensitif.
  • Metrik kegagalan verifikasi tersedia untuk monitoring.
  • Pemrosesan bisnis dibuat idempotent.
  • Pengujian mencakup body yang berubah, timestamp kedaluwarsa, secret salah, dan replay.

Penutup

Validasi signature webhook yang aman bergantung pada detail kecil: payload mentah, urutan verifikasi, timestamp, toleransi drift, dan perlindungan replay. HMAC yang benar saja tidak cukup jika body sudah berubah sebelum diverifikasi atau request lama masih diterima dalam sistem Anda.

Jika Anda sedang membangun integrasi pihak ketiga, fokuslah pada kontrak request yang aktual dari provider, bukan asumsi umum. Ambil raw body, verifikasi timestamp, bangun canonical string dengan tepat, gunakan constant-time comparison, lalu tambahkan replay detection dan audit yang memadai. Dengan pendekatan ini, Anda bisa mengurangi webhook palsu, replay, sekaligus mencegah false negative pada event yang sah.