Skip to content
Kembali ke Blog
Keamanan

Verifikasi Tanda Tangan Webhook Gagal: Penyebab dan Solusi

Verifikasi tanda tangan webhook gagal? Biasanya karena raw body, encoding digest, atau prefiks timestamp yang terlewat. Debug dengan alat HMAC online gratis.

15 menit membaca

Verifikasi Tanda Tangan Webhook Gagal? Temukan Penyebabnya

Error webhook signature verification failed berarti satu hal saja: digest yang dihitung kode Anda tidak sama dengan digest di header permintaan. Pesan itu tidak menyinggung izin maupun kedaluwarsa, dan penyebabnya hampir tidak pernah bug di SDK provider. Ada sesuatu yang berbeda antara byte yang di-hash provider dan byte yang Anda hash.

Empat input menentukan hasilnya: byte mana yang ditandatangani, byte kunci mana yang dipakai, algoritma hash mana yang berjalan, dan pengkodean (encoding) teks mana yang Anda pakai saat membandingkan. Salah satu saja keliru, bentuk kegagalannya identik. Pesan error tidak membawa petunjuk sedikit pun tentang yang mana. Membacanya lebih teliti tidak menambah informasi, jadi yang perlu Anda kerjakan adalah mempersempit ruang input.

Pilih cabang awal Anda:

Tanda tangan tidak cocok? Tiga cabang:
├─ Apakah framework Anda mem-parse JSON sebelum Anda melihatnya?    → Bagian 3
├─ Apakah nilai header punya prefiks, atau terlihat seperti base64? → Bagian 4
└─ Apakah header provider mengandung timestamp?                     → Bagian 2

1. Arti sebuah ketidakcocokan tanda tangan

Verifikasi adalah perbandingan dua deretan byte. Ketika ia gagal, salah satu dari empat hal berikut yang keliru, dan keempatnya saling independen.

Byte mana yang ditandatangani. Provider meng-hash satu urutan byte tertentu. Bisa jadi itu body permintaan saja, bisa jadi timestamp yang ditempelkan di depan body. Kalau framework Anda sudah mem-parse JSON dan menyerahkan sebuah objek kepada Anda, byte aslinya sudah tidak ada dan Anda tidak bisa merekonstruksinya secara andal. Ini isi Bagian 3, dan dalam praktik inilah penyebab yang paling sering muncul.

Byte kunci mana yang dipakai. String secret yang sama bisa dibaca sebagai teks UTF-8, sebagai hex, atau sebagai base64, dan setiap cara baca menghasilkan kunci yang berbeda. Begitu juga secret yang membawa satu baris baru ekstra karena ikut terbaca oleh pemuat konfigurasi. Ada kegagalan kedua yang bersembunyi di dimensi ini: secret-nya bisa saja memang secret yang salah, bukan cara baca yang salah atas secret yang benar. Itu isi Bagian 6.

Pengkodean mana yang Anda pakai saat membandingkan. Digest SHA-256 adalah 32 byte mentah. Hex dan base64 adalah dua cara menuliskan byte yang sama itu sebagai teks, dan keduanya tidak pernah terlihat mirip. Bandingkan yang satu dengan yang lain, Anda dapat hmac signature mismatch permanen padahal byte di bawahnya sudah cocok.

Algoritma hash mana yang berjalan. Sebagian besar provider memakai SHA-256 dan mendokumentasikannya, jadi dimensi ini biasanya tidak memakan waktu Anda. GitHub adalah pengecualian yang perlu Anda tahu: setiap pengiriman membawa X-Hub-Signature (HMAC-SHA1) berdampingan dengan X-Hub-Signature-256 (HMAC-SHA256), dan dokumentasi GitHub sendiri menyebut header SHA-1 itu “hanya disertakan untuk alasan kompatibilitas lama”, lalu menganjurkan varian 256. Salah baca header, dan panjangnyalah yang membocorkan masalahnya lebih dulu, bukan byte-nya. Body dari Bagian 2, ditandatangani dengan secret yang sama memakai SHA-1, menghasilkan sha1=ba2954d180839d8170b08b32cd38483775aaae96, yaitu 40 karakter hex melawan 64 karakter digest SHA-256-nya.

Jaga keempat hal itu tetap terpisah selama Anda debug. Cara tercepat mengisolasi satu dimensi adalah menghitung digest di luar aplikasi Anda, dari input yang Anda kendalikan sendiri: tempelkan sebuah body dan sebuah secret ke generator HMAC lalu lihat hasilnya. Alat itu berjalan sepenuhnya di browser Anda dan secret-nya tidak pernah meninggalkan halaman, jadi secret penanda tangan produksi pun aman Anda tempelkan ke situ. HMAC menjalankan primitif SHA-256 yang sama dengan hash SHA-256 biasa, hanya saja diberi kunci berupa secret Anda. Jadi kalau Anda bisa mereproduksi nilai provider dengan tangan, kriptografinya sehat dan bug-nya ada di penanganan permintaan Anda.

2. String yang ditandatangani empat provider besar

Sebagian besar integrasi tersandung di satu asumsi: setiap provider menandatangani body permintaan dan tidak ada yang lain. Dua dari empat provider terbesar tidak begitu. Berikut yang di-hash masing-masing, menurut dokumentasi provider yang berlaku sekarang:

ProviderHeaderString yang ditandatanganiPengkodeanPrefiks nilaiSecretToleransi timestamp
StripeStripe-Signature{timestamp} + . + rawBodyhext=…,v1=…,v0=…secret penanda tangan endpoint (berprefiks whsec_)5 menit (300 detik)
GitHubX-Hub-Signature-256rawBody (tanpa prefiks)hexsha256=token secret webhooktidak ada (timestamp tidak dikirim)
SlackX-Slack-Signature + X-Slack-Request-Timestampv0: + {timestamp} + : + rawBodyhexv0=signing secret5 menit
ShopifyX-Shopify-Hmac-SHA256rawBodybase64tidak adaclient secret aplikasi (bukan secret webhook terpisah)tidak ada

Empat provider ini kebetulan menutupi tiga poros yang saling ortogonal. String yang ditandatangani entah body saja atau body yang digabung dengan timestamp, dan bahkan pemisahnya berbeda: Stripe memakai . sedangkan Slack memakai :. Pengkodeannya hex untuk tiga provider dan base64 untuk satu. Secret-nya berasal dari kredensial webhook khusus untuk tiga provider, dan dari client secret aplikasi untuk Shopify. Detail terakhir ini yang paling sering salah diambil orang, karena di UI admin ada field berlabel “webhook” yang justru bukan yang Anda butuhkan. Tabel di atas mencakup empat provider global. Kalau integrasi Anda justru dengan gateway pasar Indonesia, Midtrans, Xendit, atau DOKU, susun tabel yang sama dari dokumentasi masing-masing sebelum Anda menulis kode verifikasinya, karena tidak satu pun kolom itu bisa Anda simpulkan lewat analogi.

Supaya perbedaannya konkret, ini satu body yang ditandatangani dengan empat cara memakai satu secret:

body   : {"id":42,"event":"user.created"}
secret : whsec_test_secret
ts     : 1700000000
BentukNilai
Gaya GitHubsha256=09dd9fef34ca68915e1ba93eb7515cbc33e7e753806767f81abc6409480c846b
Gaya ShopifyCd2f7zTKaJFeG6k+t1FcvDPn51OAZ2f4GrxkCUgMhGs=
Gaya Stripet=1700000000,v1=4b56de5a58122bab8ebbadbed663fbc17d810096d57498f5b24a72f5123b2375
Gaya Slackv0=3faf37337484c62dcd1a6c1ff308d1345c31291e4aac9b44554a99e8e35a1f9c

Baca dua baris pertama bersamaan, karena keduanya adalah digest 32 byte yang sama, ditulis dua kali. Enam puluh empat karakter hex, atau empat puluh empat karakter base64 termasuk padding. Tidak ada satu pun ciri kedua string itu yang mengisyaratkan bahwa keduanya sama, dan itulah sebabnya ketidakcocokan antar pengkodean bertahan meski Anda sudah memeriksa berulang kali bahwa secret-nya benar.

Dua baris terakhir membuktikan separuh sisanya. Body yang sama, secret yang sama, algoritma yang sama, dan tidak satu pun digest-nya menyerupai digest GitHub, karena string yang di-hash sekarang dimulai dengan timestamp. Sebagian besar laporan error stripe webhook signature verification failed berujung di baris ini: kodenya meng-hash body sendirian dan tidak pernah menempelkan nilai t beserta titiknya di depan. Reproduksi keempatnya di generator HMAC: ubah hanya field pesannya, lalu ganti format keluarannya.

Satu konsekuensi praktis dari kolom timestamp: digest Stripe atau Slack hanya sah selama beberapa menit, jadi Anda tidak bisa menangkap sebuah tanda tangan hari ini lalu memutarnya kembali dalam pengujian besok. Tanda tangan GitHub dan Shopify stabil selamanya. Itu membuatnya jauh lebih mudah di-debug, tapi perlindungan terhadap replay jadi tanggung jawab Anda sendiri.

3. Masalah raw body

Sebagian besar laporan webhook signature verification failed bermuara ke bagian ini: byte yang Anda hash bukan lagi byte yang tiba.

Framework Anda sudah menghancurkan byte-nya

Framework web dibangun untuk membebaskan Anda dari urusan parsing. Kemudahan itulah yang merusak verifikasi tanda tangan, karena begitu handler Anda berjalan, byte aslinya sudah lenyap.

express.json() membaca stream permintaan, mem-parse-nya, lalu menggantikan req.body dengan sebuah objek JavaScript. Stream itu sudah dikonsumsi dan tidak bisa dibaca lagi. Di FastAPI, mendeklarasikan model Pydantic atau parameter body bertipe dict membuat framework membaca dan mem-parse sebelum fungsi Anda mulai jalan. Rails mengisi params dari body JSON melalui middleware yang jalan sebelum action controller Anda. Konverter Jackson di Spring mengubah body menjadi kelas DTO Anda, dan secara bawaan input stream HttpServletRequest di bawahnya hanya bisa dibaca sekali.

Tidak ada satu pun di sini yang merupakan bug. Semuanya mengerjakan persis apa yang Anda konfigurasikan. Masalahnya, tanda tangan mencakup byte, objek bukan byte, dan mengubah objek kembali menjadi byte adalah operasi yang berbeda dari yang dilakukan provider.

Kenapa serialisasi ulang kadang berhasil

Nasihat yang biasa beredar: serialisasi ulang mengubah byte-nya. Itu belum lengkap, dan separuh yang hilang itulah yang membuat kegagalan ini sulit didiagnosis. Kadang ia tidak mengubah apa pun sama sekali.

Ini hasil pengukuran JSON.stringify(JSON.parse(body)) === body pada berbagai bentuk payload:

Bentuk payloadByte setelah round-tripPerubahan
{"id":42,"event":"user.created"}identiktidak ada, dan itulah sebabnya pengujian lokal lolos
{"amount":1.0}berubah{"amount":1}
{"n":1e3}berubah{"n":1000}
{"id":12345678901234567890}berubah{"id":12345678901234567000} (presisi hilang)
{"name":"caf\u00e9"}berubah{"name":"café"} (6 byte menjadi 2)
{"a":1}\nberubahbaris baru di akhir tertelan
{ "a" : 1 }berubahspasi di dalam tertelan
{"v":-0.0}berubah{"v":0}
{"p":0.1000000000000000055511151231257827}berubah{"p":0.1}

Perhatikan baris pertama. Objek datar berisi satu integer dan satu string ASCII pendek melewati round-trip byte demi byte tanpa berubah, jadi verifier yang mem-parse lalu men-stringify ulang akan lolos di setiap tes yang Anda tulis dengan fixture seperti itu. Lalu Anda deploy, dan payload pertama yang membawa nilai uang 1.0, ID di atas 2^53, atau nama pelanggan bertanda aksen langsung gagal. Yang gagal bukan semua event, hanya event yang seperti itu.

Itulah mekanisme di balik “jalan di lokal, 401 sesekali di produksi”, dan situasinya jauh lebih buruk daripada verifier yang gagal terus-menerus. Verifier yang selalu gagal beres dalam satu jam. Verifier yang gagal pada 3% event membuat tim menyalahkan provider, me-retry, mengeskalasi, lalu menahan masalahnya berminggu-minggu. Kalau tingkat kegagalan Anda ada di antara nol dan seratus persen, mulailah dari tabel di atas.

Urutan key adalah penyebab yang orang duga paling dulu dan justru paling tidak mungkin dalam praktik, karena JSON.parse mempertahankan urutan penyisipan untuk key bertipe string. Angka dan spasilah pelakunya.

Mengambil raw body di masing-masing framework

Express, dengan parser khusus rute yang Anda daftarkan sebelum parser JSON global:

const express = require('express');
const crypto = require('crypto');
const app = express();

// Rute ini harus didaftarkan SEBELUM app.use(express.json()).
// body-parser menandai permintaan sebagai sudah di-parse, jadi raw() yang
// menyusul akan diam-diam menghasilkan {}.
app.post('/webhooks/github', express.raw({ type: 'application/json' }), (req, res) => {
  const raw = req.body; // sebuah Buffer, bukan objek
  const digest = crypto
    .createHmac('sha256', process.env.WEBHOOK_SECRET)
    .update(raw) // hash Buffer-nya langsung, tanpa toString()
    .digest('hex');
  console.log('bytes:', raw.length, 'digest:', digest);
  res.sendStatus(200);
});

app.use(express.json()); // rute lain tetap menerima JSON yang sudah di-parse
app.listen(3000);

Kalau Anda tidak bisa mengubah urutan middleware, simpan salinannya selama parsing:

app.use(express.json({
  verify: (req, res, buf) => { req.rawBody = Buffer.from(buf); },
}));

FastAPI. Starlette menyimpan cache body, jadi await request.body() mengembalikan byte asli bahkan di handler yang juga menerima model hasil parsing:

import hashlib, hmac, os
from fastapi import FastAPI, HTTPException, Request

app = FastAPI()

@app.post("/webhooks/github")
async def github(request: Request):
    raw = await request.body()  # byte, persis seperti yang diterima
    expected = "sha256=" + hmac.new(
        os.environ["WEBHOOK_SECRET"].encode("utf-8"), raw, hashlib.sha256
    ).hexdigest()
    received = request.headers.get("X-Hub-Signature-256", "")
    if not hmac.compare_digest(expected, received):
        raise HTTPException(status_code=401, detail="bad signature")
    return {"ok": True}

Rails, di mana request.raw_post memberi Anda body yang belum di-parse sebagai string:

class WebhooksController < ApplicationController
  skip_before_action :verify_authenticity_token

  def shopify
    raw = request.raw_post
    digest = Base64.strict_encode64(
      OpenSSL::HMAC.digest('sha256', ENV['SHOPIFY_CLIENT_SECRET'], raw)
    )
    unless OpenSSL.secure_compare(digest, request.headers['X-Shopify-Hmac-SHA256'].to_s)
      return head :unauthorized
    end
    head :ok
  end
end

Go, di mana Anda membaca body sendiri dan harus ingat bahwa setelahnya body itu terkuras habis:

func handler(w http.ResponseWriter, r *http.Request) {
	raw, err := io.ReadAll(r.Body)
	if err != nil {
		http.Error(w, "unreadable body", http.StatusBadRequest)
		return
	}
	mac := hmac.New(sha256.New, []byte(os.Getenv("WEBHOOK_SECRET")))
	mac.Write(raw)
	expected := mac.Sum(nil)

	got, err := hex.DecodeString(
		strings.TrimPrefix(r.Header.Get("X-Hub-Signature-256"), "sha256="))
	if err != nil || !hmac.Equal(expected, got) {
		http.Error(w, "bad signature", http.StatusUnauthorized)
		return
	}
	// Unmarshal dari raw, jangan pernah dari r.Body — byte-nya sudah habis.
	w.WriteHeader(http.StatusOK)
}

Spring, di mana meminta byte[] melewati Jackson sepenuhnya:

@PostMapping(path = "/webhooks/github", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Void> github(@RequestBody byte[] payload,
                                   @RequestHeader("X-Hub-Signature-256") String header)
        throws GeneralSecurityException {
  Mac mac = Mac.getInstance("HmacSHA256");
  mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
  String expected = "sha256=" + HexFormat.of().formatHex(mac.doFinal(payload));
  boolean ok = MessageDigest.isEqual(expected.getBytes(StandardCharsets.UTF_8),
                                     header.getBytes(StandardCharsets.UTF_8));
  return ok ? ResponseEntity.ok().build()
            : ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}

ContentCachingRequestWrapper adalah alternatifnya ketika Anda harus memeriksa di sebuah filter dan tidak bisa mengubah signature controller. Ia punya jebakan sendiri: getContentAsByteArray() baru mengembalikan byte setelah ada sesuatu di hilir yang membaca stream, jadi memanggilnya sebelum chain.doFilter(...) hanya memberi Anda array kosong.

4. Ketidakcocokan pengkodean: hex, base64, dan kuncinya sendiri

Ada tiga keputusan pengkodean terpisah yang duduk di antara digest Anda dan nilai header, dan masing-masing bisa mematahkan perbandingan sendirian.

Pengkodean digest. Keluaran HMAC-SHA256 adalah 32 byte. Ditulis sebagai hex huruf kecil panjangnya 64 karakter; ditulis sebagai base64 standar panjangnya 44 termasuk padding =. Dua baris dari Bagian 2 menunjukkannya langsung:

PengkodeanKarakter32 byte yang sama, ditulis sebagai
hex6409dd9fef34ca68915e1ba93eb7515cbc33e7e753806767f81abc6409480c846b
base6444Cd2f7zTKaJFeG6k+t1FcvDPn51OAZ2f4GrxkCUgMhGs=

Heuristik cepat saat Anda menatap header yang tidak Anda kenal: kalau nilainya 64 karakter dari himpunan 0-9a-f, itu hex. Kalau panjangnya 44 karakter dan diakhiri =, atau memuat +, /, atau huruf kapital, itu base64. Kalau Anda ingin memastikan alih-alih menduga, jalankan nilai base64 itu lewat dekoder Base64 dan periksa apakah hasilnya 32 byte; kalau iya, kedua string menggambarkan digest yang sama dan yang Anda bandingkan sebenarnya format teks, bukan tanda tangan.

Prefiks nilai. GitHub mengirim sha256= di depan hex-nya. Slack mengirim v0=. Stripe membungkus semuanya dalam daftar pasangan key=value yang dipisah koma. Tidak satu pun karakter itu bagian dari digest, jadi entah Anda buang prefiks dari header atau Anda tambahkan prefiks ke nilai Anda sendiri. Tidak melakukan keduanya adalah alasan paling umum sebuah implementasi yang benar melaporkan hmac signature mismatch. Di Node ia bahkan tidak melaporkan ketidakcocokan, seperti dijelaskan Bagian 7.

Pengkodean kunci. Secret juga berupa byte, dan string yang sama dibaca sebagai UTF-8, hex, atau base64 memberi tiga kunci yang berbeda. Provider yang menyerahkan token teks seperti whsec_... menginginkan UTF-8, tetapi banyak sistem internal mendistribusikan secret base64 atau hex yang harus didekode dulu sebelum dipakai menandatangani. Bentuk kegagalannya identik dengan versi JWT dari masalah ini, dan dibahas mendalam di JWT invalid signature: semua penyebab dan cara memperbaikinya, termasuk cara mengenali apakah sebuah secret berupa base64 atau teks biasa.

5. Timestamp, toleransi, dan jendela replay

Anda bisa menghitung digest yang cocok sempurna dan tetap ditolak. Provider yang menyertakan timestamp mengharapkan Anda memeriksanya, dan timestamp yang sudah basi adalah tanda tangan sah yang tetap harus Anda tolak.

ProviderLokasi timestampJendela
Stripet= di dalam Stripe-Signature5 menit (300 detik)
Slackheader X-Slack-Request-Timestamp5 menit
GitHubtidak dikirimtidak berlaku
Shopifytidak dikirimtidak berlaku

Salah menyetel jendela ini merugikan di kedua arah. Kalau terlalu longgar, permintaan yang tertangkap tetap bisa diputar ulang selama apa pun yang Anda izinkan, dan pemeriksaan timestamp kehilangan hampir seluruh gunanya. Kalau terlalu ketat, pergeseran jam (clock drift) biasa mulai menolak pengiriman yang sah. Lima menit adalah pilihan kedua provider itu, dan menirunya adalah default yang sehat.

Sebelum Anda melebarkan toleransi, periksa jamnya. Image container tidak menjalankan NTP, dan VM yang dilanjutkan dari snapshot bisa tertinggal beberapa menit dari waktu dinding tanpa satu pun baris log yang memberitahu. Host yang bergeser terus-menerus menghasilkan kegagalan yang mula-mula sesekali lalu menjadi total. Polanya terbaca seperti regresi kode, padahal bukan.

Bug jam yang lain adalah satuan yang tidak cocok. Setiap provider di tabel mengirim epoch dalam detik. Bandingkan itu dengan nilai milidetik seperti Date.now() di JavaScript, dan selisihnya kira-kira seribu kali umur aslinya, sehingga semua event jatuh di luar jendela mana pun yang masuk akal. Gejalanya: pemeriksaan toleransi menolak seratus persen pengiriman padahal digest-nya sendiri cocok. Kalau Anda tidak yakin satuan mana yang sedang Anda pegang, panjangnya adalah petunjuknya, dan epoch detik versus milidetik membahas konversinya sekaligus jebakan zona waktu di sekitarnya.

Pakai string timestamp mentah dari header saat Anda menyusun string yang ditandatangani, bukan angka hasil parsing yang diformat ulang. Mem-parse 1700000000 menjadi float lalu mencetaknya kembali bisa menghasilkan 1700000000.0, dan itu urutan byte yang berbeda.

6. Secret yang salah, dan secret yang dirotasi

Sebelum Anda masuk lebih jauh ke soal pengkodean, buang dulu penyebab yang paling sederhana: secret-nya mungkin memang bukan secret yang benar. Dokumentasi Stripe menyatakan terang-terangan bahwa “Stripe membuat satu kunci rahasia unik untuk setiap endpoint”, dan kalau Anda mengarahkan URL yang sama ke kunci test sekaligus kunci live, “secret-nya berbeda untuk masing-masing”. Dari situ muncul tiga versi dari satu kekeliruan yang sama.

Mode test dan mode live menyimpan secret yang terpisah, jadi nilai yang Anda salin sewaktu dasbor masih di mode test akan gagal di setiap pengiriman live. Tiap endpoint menyimpan miliknya sendiri, dan dokumentasinya menambahkan bahwa “kalau Anda memakai beberapa endpoint, Anda harus mengambil satu secret untuk setiap endpoint yang tanda tangannya ingin Anda verifikasi”: arahkan dua endpoint ke satu handler dengan satu secret di environment, dan separuh trafik Anda gagal. Lalu stripe listen mencetak signing secret untuk penerusan lokal di CLI, dan itu endpoint yang terpisah dari apa pun yang terdaftar di dasbor, jadi keduanya tidak bisa saling menggantikan.

Dilihat dari luar, tidak satu pun dari kasus ini terlihat seperti bug pengkodean. Digest-nya rapi, perbandingannya benar, dan nilai di environment Anda memang secret Stripe asli, hanya saja bukan secret yang menandatangani pengiriman ini.

Rotasi adalah dimensi yang sama, cuma bergerak di bawah kaki Anda. Ia paling jarang terlihat seperti masalah pengkodean, jadi orang paling sering salah mendiagnosisnya sebagai bug kode. Tidak ada yang berubah di kode Anda, verifikasi jalan kemarin, dan sekarang sebagian event gagal.

Jendela tumpang tindih itu memang disengaja. Stripe membiarkan secret endpoint lama tetap sah sampai 24 jam setelah Anda merotasi, dan selama periode itu header Stripe-Signature membawa satu tanda tangan v1 untuk setiap secret yang aktif. Shopify bergerak ke arah sebaliknya: setelah rotasi, bisa perlu sampai satu jam sebelum ia mulai memakai secret baru untuk menghitung digest, jadi yang Anda butuhkan sementara itu justru yang lama.

Perilaku Stripe-lah yang mematahkan kode, karena header-nya terlihat seolah hanya memuat satu tanda tangan. Memecah dengan , lalu mengambil v1 pertama yang ditemukan berjalan mulus sampai ada dua, dan sejak titik itu Anda cocok kira-kira separuh waktu, tergantung secret mana yang menandatangani event mana. Iterasi ke semuanya:

const crypto = require('crypto');

function verifyStripe(header, rawBody, secret, toleranceSec = 300) {
  let t = null;
  const v1 = [];
  for (const pair of header.split(',')) {
    const idx = pair.indexOf('=');
    const key = pair.slice(0, idx);
    const value = pair.slice(idx + 1);
    if (key === 'v1') v1.push(value);
    else if (key === 't') t = value; // simpan string aslinya
  }
  if (t === null || v1.length === 0) return false;

  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(t));
  if (!Number.isFinite(age) || age > toleranceSec) return false;

  const signedPayload = Buffer.concat([Buffer.from(`${t}.`, 'utf8'), rawBody]);
  const expected = crypto.createHmac('sha256', secret).update(signedPayload).digest();

  return v1.some((sig) => {
    const received = Buffer.from(sig, 'hex');
    return received.length === expected.length &&
      crypto.timingSafeEqual(received, expected);
  });
}

Dua detail di sana penting di luar soal loop-nya. Timestamp masuk ke payload yang ditandatangani sebagai string apa adanya seperti saat ia tiba, dan body digabungkan sebagai byte alih-alih lewat interpolasi template, yang akan mendekodenya sebagai UTF-8 lebih dulu.

Bentuk yang sama berlaku ketika Anda merotasi di sisi Anda: terima secret lama dan secret baru selama masa tumpang tindih, lalu buang yang lama. Apa pun yang Anda jadikan tujuan rotasi butuh entropi penuh, jadi bangkitkan alih-alih mengetiknya, misalnya dengan generator secret penanda tangan untuk nilai acak 256 bit.

7. Membandingkan tanda tangan tanpa membocorkan waktu

Setelah Anda punya dua digest, cara Anda membandingkannya adalah keputusan keamanan. Perbandingan string biasa berhenti begitu ia menemukan byte yang berbeda, jadi waktu yang ia butuhkan mengungkap berapa byte di depan yang sudah benar. Penyerang yang bisa mengirim banyak permintaan memanfaatkan itu untuk memulihkan tanda tangan yang sah satu byte demi satu byte. Cara ini lambat dan berisik lewat internet, dan sepenuhnya praktis di jaringan lokal.

Setiap runtime menyediakan perbandingan waktu tetap:

BahasaPerbandingan waktu konstanSaat panjangnya berbeda
Nodecrypto.timingSafeEqual(a, b)melempar error
Pythonhmac.compare_digest(a, b)mengembalikan False
Gohmac.Equal(a, b)mengembalikan false
PHPhash_equals($known, $user)mengembalikan false
RubyOpenSSL.secure_compare(a, b)mengembalikan false

Kolom terakhir itu sumber satu kelas insiden yang membingungkan. Node adalah pengecualiannya, dan ia tidak gagal dengan sopan:

RangeError [ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH]: Input buffers must have the same byte length

Digest SHA-256 dalam hex panjangnya 64 karakter. Nilai di X-Hub-Signature-256 panjangnya 71, karena sha256= ada tujuh karakter. Kalau Anda lupa membuang prefiksnya, kedua buffer punya panjang berbeda, jadi timingSafeEqual melempar error alih-alih mengembalikan false. Kalau tidak ditangkap, exception itu menjalar keluar dari handler Anda dan Express mengubahnya menjadi 500.

Anda sedang mencari respons webhook 401 unauthorized dan yang Anda dapat adalah server error, jadi Anda pergi membaca handler Anda, pemanggilan database, dan event dispatcher. Padahal bug-nya ada satu baris di atas perbandingan itu. Membandingkan digest hex 64 karakter dengan digest base64 44 karakter melempar error dengan alasan yang sama, yang berarti ketidakcocokan pengkodean di Node juga muncul sebagai 500 alih-alih penolakan yang bersih.

Perbaikannya: periksa panjangnya sendiri lalu kembalikan false:

function safeEqualHex(receivedHex, expectedHex) {
  const a = Buffer.from(receivedHex, 'hex');
  const b = Buffer.from(expectedHex, 'hex');
  if (a.length !== b.length) return false; // jaga panjang sebelum memanggil
  return crypto.timingSafeEqual(a, b);
}

Membocorkan panjangnya tidak berbahaya; panjang digest sudah ditetapkan algoritma dan bersifat publik. Yang tidak boleh Anda bocorkan adalah prefiks mana yang cocok. Tab Verify di generator HMAC menggabungkan perbedaan panjang ke dalam akumulator waktu konstan yang sama alih-alih keluar lebih awal, jadi panjang yang tidak sama kembali sebagai false biasa, bukan sebagai exception, dan Anda bisa memeriksa nilai header terhadap digest hitungan Anda tanpa menulis kode sekali pakai.

8. Ketika lapisan transport mengubah byte Anda

Anda sudah menyingkirkan string yang ditandatangani, raw body, pengkodean, jam, dan rotasi. Yang tersisa adalah kemungkinan bahwa byte yang tiba di proses Anda bukan byte yang meninggalkan provider.

Provider atau proxy bisa mengirim body dalam bentuk gzip dengan Content-Encoding: gzip. Tanda tangan mencakup payload yang belum terkompresi, jadi Anda harus meng-hash setelah dekompresi. Sebagian framework mendekompresi secara transparan dan sebagian menyerahkan byte terkompresi kepada Anda; body yang terlihat seperti sampah biner di log Anda adalah tandanya.

Dengan Transfer-Encoding: chunked tidak ada Content-Length, dan kode yang memercayai header itu untuk menentukan ukuran buffer baca akan memotong body. Digest dari body yang terpotong adalah omong kosong yang valid: ia tidak akan pernah cocok, dan tidak ada yang terlihat salah.

Lapisan apa pun yang membaca lalu menulis ulang body bisa mengubahnya. AWS API Gateway bisa meng-encode body ke base64 sebelum ia mencapai Lambda, jadi Anda harus mendekode dulu sebelum meng-hash. Application load balancer, service mesh, dan web application firewall juga bisa menormalkan atau meng-encode ulang payload. Uji dengan membandingkan panjang byte yang dilihat handler Anda terhadap Content-Length yang dikirim provider.

Payload bisa memuat karakter non-ASCII, dan dokumentasi GitHub tegas menyatakan bahwa payload harus ditangani sebagai UTF-8. Mendekode body menjadi string dengan charset yang salah lalu meng-encode-nya ulang menghancurkan setiap karakter multi-byte. Sebuah byte order mark UTF-8, EF BB BF, yang ditempelkan editor atau serializer yang bermaksud baik menambah tiga byte yang tidak pernah ditandatangani.

Body yang melintasi batas berkas mode teks bisa tiba dengan LF yang sudah ditulis ulang menjadi CRLF. Baca juga spesifikasi provider untuk string penandatanganan yang tepat: sebagian menambahkan karakter mereka sendiri, dan Typeform adalah kasus terdokumentasi di mana baris baru di akhir menjadi bagian dari yang di-hash. Ketika dokumentasi sebuah provider menyebut karakter ekstra apa pun, artikan secara harfiah.

9. Alur kerja debugging yang bisa diulang

Jalankan langkah-langkah ini berurutan. Setiap langkah entah menemukan bug-nya atau mengeliminasi satu cabang, jadi berhentilah begitu salah satunya menjawab.

  1. Log byte mentahnya sebelum middleware mana pun berjalan. Tulis body ke sebuah berkas, atau log panjang byte-nya plus SHA-256-nya, dari titik paling awal dalam siklus hidup permintaan yang bisa Anda jangkau. Panjangnya sendirian sudah menyelesaikan banyak kasus: nilai yang lebih besar satu dari perkiraan berarti baris baru di akhir, lebih besar tiga berarti BOM.
  2. Hitung digest-nya dengan tangan. Tempelkan byte yang persis itu beserta secret Anda ke generator HMAC, pilih SHA-256, dan setel format keluaran agar cocok dengan header. Langkah ini memotong ruang masalahnya jadi dua dengan bersih.
  3. Bandingkan nilai hitungan tangan itu dengan header. Sama berarti byte dan secret keduanya benar dan bug-nya ada di suatu tempat pada jalur kode Anda, jadi pergilah membaca perbandingan Anda. Tidak sama berarti salah satu input keliru, jadi lanjutkan.
  4. Cek string yang ditandatangani terhadap tabel di Bagian 2. Apakah provider ini menempelkan timestamp di depan? Dengan pemisah apa? Tambahkan prefiksnya di alat itu lalu hitung ulang.
  5. Ganti pengkodean digest-nya. Hitung ulang sebagai hex dan sebagai base64 lalu bandingkan keduanya dengan header. Nilai header 44 karakter dengan = di ujung adalah base64, apa pun yang diasumsikan kode Anda.
  6. Ganti pengkodean kuncinya. Coba secret-nya sebagai teks, lalu hex, lalu base64. Salah satu dari tiga itu biasanya menghasilkan kecocokan, dan itu memberi tahu Anda apa yang diharapkan provider.
  7. Cek jam dan status rotasi. Bandingkan waktu server Anda dengan sumber yang diketahui, pastikan Anda menangani epoch dalam detik, dan periksa dasbor provider untuk rotasi dalam 24 jam terakhir.

Dua kebiasaan membuat loop ini jauh lebih cepat. Pertama, tangkap satu payload yang gagal lalu kerjakan secara offline alih-alih menunggu pengiriman berikutnya. Kedua, putar ulang body yang tertangkap itu ke endpoint Anda dengan tanda tangan tetap sehingga input-nya tidak pernah berubah antar percobaan. Generator perintah cURL merangkai permintaannya dengan header yang persis dan body yang dibaca dari berkas, sehingga byte-nya stabil di seluruh eksekusi. Begitu kegagalannya bisa Anda ulang kapan saja, laporan webhook signature verification failed yang muncul sesekali jadi mudah dilacak.

Kalau Anda masih perlu membuka tiket dukungan, sertakan panjang byte dari body yang Anda hash, nilai header apa adanya, konstruksi string yang ditandatangani yang Anda pakai, dan pengkodean digest-nya. Jangan pernah menyertakan secret-nya sendiri.

FAQ

Kenapa tanda tangan webhook saya jalan di lokal tapi gagal di produksi?

Payload uji Anda kemungkinan melewati round-trip JSON tanpa berubah, jadi men-serialisasi ulang tidak berbahaya. Payload sungguhan memuat float, integer besar, escape Unicode, atau spasi ekstra, dan hal-hal itu memang mengubah byte-nya. Tandatangani raw body alih-alih salinan hasil serialisasi ulang; tabel di Bagian 3 menunjukkan bentuk mana yang rusak.

Perlukah saya menyertakan prefiks sha256= saat membandingkan tanda tangan?

Buang prefiksnya, atau tambahkan ke nilai Anda sendiri supaya kedua string cocok persis. Digest hex hitungan Anda 64 karakter dan nilai header 71 karakter dengan prefiksnya. Sebagian fungsi perbandingan mengembalikan false saat panjangnya tidak cocok, dan timingSafeEqual di Node melempar error alih-alih mengembalikan false.

Bisakah saya memverifikasi tanda tangan setelah framework saya mem-parse JSON-nya?

Tidak secara andal. Serialisasi ulang mereproduksi byte asli hanya untuk payload tanpa float, tanpa integer di atas 2^53, tanpa escape Unicode, dan tanpa spasi ekstra. Begitu salah satu muncul, digest-nya berubah, jadi verifikasi lolos saat pengujian dan gagal pada sebagian event produksi.

Kenapa Stripe dan GitHub menghasilkan tanda tangan berbeda untuk payload yang sama?

Karena keduanya meng-hash string yang berbeda. GitHub menandatangani raw body sendirian. Stripe menandatangani timestamp, sebuah . literal, lalu body-nya, sehingga satu payload yang dikirim pada dua waktu berbeda menghasilkan dua digest berbeda. Slack menempelkan v0: dan timestamp-nya sendiri di depan. Algoritma sama, input berbeda.

Berapa lama sebaiknya toleransi timestamp disetel?

Lima menit adalah yang dipakai Stripe dan Slack, dan menirunya adalah default yang wajar. Jendela lebih pendek menolak pengiriman sah begitu jam server Anda bergeser. Jendela lebih panjang melebarkan periode di mana permintaan yang tertangkap bisa diputar ulang. Sinkronkan jam dengan NTP sebelum melonggarkan toleransi.

Apakah timingSafeEqual mengembalikan false saat panjangnya berbeda?

Tidak. Node melempar RangeError [ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH]: Input buffers must have the same byte length. Kalau tidak ditangkap, itu menjadi 500 alih-alih 401, yang mengirim Anda men-debug handler Anda bukan baris di atas perbandingan itu. Bandingkan panjangnya lebih dulu lalu kembalikan false sendiri.

Provider saya sudah merotasi secret, kenapa sebagian webhook masih gagal?

Jendela rotasi saling tumpang tindih. Stripe membiarkan secret lama tetap sah sampai 24 jam dan mengirim satu tanda tangan v1 per secret aktif, jadi kode yang hanya membaca v1 pertama gagal pada kira-kira separuh event. Shopify bisa perlu sampai satu jam untuk mulai memakai secret baru.

Kesimpulan

Verifikasi adalah perbandingan byte, jadi webhook signature verification failed selalu berujung pada perselisihan soal byte, bukan pada apa pun yang bersifat kriptografis. Selama Anda debug, pisahkan tiga hal: byte yang ditandatangani, byte kunci, dan pengkodean yang Anda bandingkan.

  • Tangkap raw body sebelum parser mana pun menyentuhnya. Jangan pernah meng-hash objek hasil serialisasi ulang, karena ia cocok cukup sering untuk meloloskan pengujian Anda dan tidak cukup sering untuk benar-benar bekerja.
  • Membaca satu secret sebagai teks, hex, dan base64 memberi tiga kunci yang berbeda.
  • Hex 64 karakter, base64 44 karakter, dan keduanya menggambarkan 32 byte yang sama.
  • Sisanya menyusul kira-kira dalam urutan kemungkinan ini: prefiks timestamp, prefiks nilai, jendela toleransi, tumpang tindih rotasi, dan lapisan transport.
  • Saat membandingkan, jaga panjangnya lebih dulu, lalu pakai fungsi waktu konstan dari runtime Anda.

Kalau Anda butuh nilai yang bisa Anda percaya sebagai pembanding, hitung di luar aplikasi Anda: tempelkan body dan secret ke generator HMAC dan biarkan ia memberi tahu sisi mana yang salah.

Tag: webhook hmac api-security debugging authentication

Artikel Terkait

Lihat semua artikel