Jika Anda memproses webhook tanpa memverifikasi tanda tangan (signature), siapa pun di internet bisa memalsukan payment_intent.succeeded ke endpoint Anda. Ini bukan teori. Ini celah autentikasi paling umum di integrasi webhook, dan penyerang mengetahuinya.
Tulisan ini adalah panduan praktis verifikasi tanda tangan untuk engineer: apa yang dilakukan setiap provider, berbagai cara halus implementasi bisa salah, dan cara menangani stempel waktu (timestamp) saat percobaan ulang (retry) dengan rapi.
TL;DR
- Stripe, GitHub, dan Shopify semuanya memakai HMAC-SHA256 atas byte raw body. Jangan menyerialisasi ulang JSON sebelum meng-hash.
- Stripe mengirim stempel waktu
tdan tanda tanganv1yang di-encode hex diStripe-Signature, dengan toleransi stempel waktu 5 menit. - GitHub mengirim
sha256=…diX-Hub-Signature-256, tanpa stempel waktu. - Shopify mengirim HMAC-SHA256 dalam base64 di
X-Shopify-Hmac-SHA256. - Selalu gunakan perbandingan waktu konstan (
crypto.timingSafeEqual), jangan pernah===. - Jika percobaan ulang merusak pemeriksaan stempel waktu Anda, yang Anda butuhkan adalah relay yang menandatangani ulang di setiap upaya.
Mengapa provider menandatangani webhook
URL webhook Anda bersifat publik. Apa pun yang memegang URL Anda bisa mengirim POST ke sana. Tanda tangan membuktikan dua hal: bahwa permintaan datang dari provider, bukan dari penyerang, dan bahwa body tidak diubah selama transit.
Penting untuk tepat soal apa yang tidak dicakupnya, karena test suite yang hijau di sini adalah bukti yang lebih lemah daripada kelihatannya. Tes Tanda Tangan Webhook Tidak Membuktikan Apa Pun memuat fixture yang lolos terhadap kode verifikasi yang bisa ditembus penyerang begitu saja. Dan tanda tangan sama sekali tidak memberi tahu apakah sebuah event benar-benar tiba, itulah sebabnya webhook pencabutan akses yang terlewat adalah insiden keamanan, bukan celah di grafik, dan itulah sebabnya log yang mencatat apa yang benar-benar tiba layak dibuat tamper-evident (setiap perubahan dapat terdeteksi).
Tanpa verifikasi, orang yang menebak atau menemukan URL webhook Anda bisa mengirimi Anda event palsu. Dengan verifikasi, mereka juga butuh signing secret, yang hanya dimiliki Anda dan provider.
Stripe
Stripe mengirim header Stripe-Signature yang bentuknya seperti ini:
t=1617223220,v1=3b8a92b...,v1=9d7ac...
t adalah stempel waktu saat webhook dikirim. v1 adalah HMAC-SHA256 dari {timestamp}.{raw_body}, di-encode hex. Beberapa entri v1 muncul selama rotasi kunci.
Langkah verifikasi:
- Ambil
tdan semua nilaiv1dari header. - Tolak jika
tlebih lama dari sekitar 5 menit (perlindungan terhadap serangan replay). - Hitung
HMAC_SHA256(signing_secret, t + "." + raw_body). - Bandingkan dalam waktu konstan dengan setiap
v1, dan terima jika ada yang cocok.
import crypto from "node:crypto";
// timingSafeEqual throws on inputs of different lengths, so check that first
function safeEqual(a: string, b: string): boolean {
const x = Buffer.from(a);
const y = Buffer.from(b);
return x.length === y.length && crypto.timingSafeEqual(x, y);
}
function verifyStripe(body: string, header: string, secret: string): boolean {
const parts = Object.fromEntries(
header.split(",").map((p) => p.split("=").slice(0, 2) as [string, string])
);
const t = parts.t;
const v1s = header.match(/v1=([a-f0-9]+)/g)?.map((x) => x.slice(3)) ?? [];
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.${body}`)
.digest("hex");
return v1s.some((sig) => safeEqual(expected, sig));
}
Kesalahan yang umum di sini adalah mem-parse body sebagai JSON lebih dulu, lalu mengubahnya kembali menjadi string. Itu mengubah whitespace. Tanda tangannya dihitung atas byte mentah, jadi Anda harus memverifikasi terhadap byte persis yang Anda terima.
GitHub
GitHub mengirim X-Hub-Signature (SHA1, versi lama) sekaligus X-Hub-Signature-256 (SHA256). Selalu gunakan versi 256.
Format: sha256=3b8a92b...
Verifikasi:
function verifyGitHub(body: string, header: string, secret: string): boolean {
const expected = "sha256=" + crypto
.createHmac("sha256", secret)
.update(body)
.digest("hex");
return safeEqual(expected, header);
}
Lebih sederhana daripada Stripe, tanpa stempel waktu dan tanpa perlindungan terhadap serangan replay. GitHub mengandalkan secret yang tetap rahasia.
Shopify
Shopify mengirim X-Shopify-Hmac-SHA256 berupa HMAC-SHA256 dari raw body dalam base64, bukan hex.
function verifyShopify(body: string, header: string, secret: string): boolean {
const expected = crypto
.createHmac("sha256", secret)
.update(body)
.digest("base64");
return safeEqual(expected, header);
}
Jebakan yang sama dengan Stripe: melewatkan body ke serializer JSON sebelum meng-hash akan merusaknya. Hash byte mentahnya.
Masalah stempel waktu saat percobaan ulang
Toleransi stempel waktu 5 menit dari Stripe berarti, jika Stripe mencoba ulang sebuah event 10 menit setelah upaya pertama dengan header aslinya, stempel waktunya sudah basi menurut standar logika percobaan ulang itu sendiri.
Dalam praktiknya, Stripe membuat ulang tanda tangan di setiap percobaan ulang dengan t yang baru, jadi ini berfungsi. Namun, di sistem yang menyimpan cache lalu memutar ulang (replay), atau yang berada di belakang antrean, Anda bisa berakhir memutar ulang event dengan stempel waktu yang basi. Perbaikannya adalah menandatangani ulang dengan stempel waktu baru di titik batas tempat Anda memutar ulang, memakai kunci penandatanganan Anda sendiri.
Itulah persisnya yang dilakukan AnyHook. Header asli dari provider diteruskan utuh, jadi kode verifikasi Anda tetap berfungsi pada upaya pertama (pada percobaan ulang atau putar ulang, header itu membawa stempel waktu asli dari provider), tetapi AnyHook juga menandatangani setiap pengiriman keluar dengan header AnyHook-Signature miliknya sendiri. Header itu berupa HMAC-SHA256, di-encode hex, dalam bentuk yang kompatibel dengan Stripe, t={ts},v1={hex}. Header ini ditandatangani ulang di setiap percobaan ulang dengan stempel waktu baru, jadi toleransi 5 menit tetap berfungsi dengan benar bahkan selama rentetan percobaan ulang yang panjang, dan kuncinya adalah signing secret per tujuan, jadi bocornya secret satu endpoint tidak membuka yang lain.
function verifyAnyHook(body: string, header: string, secret: string): boolean {
const t = header.match(/t=(\d+)/)?.[1];
const v1 = header.match(/v1=([a-f0-9]+)/)?.[1];
if (!t || !v1) return false;
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.${body}`)
.digest("hex");
return safeEqual(expected, v1);
}
Satu fungsi verifikasi, bukan tiga, dan tidak ada keanehan stempel waktu saat percobaan ulang. Provider tetap menandatangani permintaan aslinya, AnyHook memverifikasinya di edge (Cloudflare Worker AnyHook di in.anyhook.net), lalu menandatangani ulang setiap pengiriman dengan tanda tangan barunya sendiri.
Checklist: hal yang harus dilakukan dengan benar
- Verifikasi terhadap byte raw body, bukan JSON yang diserialisasi ulang.
- Gunakan perbandingan waktu konstan (
crypto.timingSafeEqual), bukan===. - Tolak permintaan yang tidak membawa header tanda tangan, dan jangan jadikan “izinkan” sebagai default.
- Toleransi stempel waktu: 5 menit adalah standar. Lebih pendek lebih aman, tetapi merusak percobaan ulang yang sah.
- Enkripsi signing secret saat disimpan (at rest), jangan taruh di env var di laptop developer.
- Rotasi signing secret secara terjadwal, dan tangani rentang rotasinya (kunci lama dan baru sama-sama berlaku selama 24 jam).
Poin raw body paling sering menjebak di serverless, karena framework sudah membaca stream sebelum kode Anda berjalan. Menerima Webhook di Vercel tanpa Ada yang Hilang memuat versi App Router dan Pages Router, termasuk body parser yang diam-diam merusaknya.
Kesimpulan
Sebagian besar provider memakai HMAC-SHA256 atas raw body; Twilio, Intercom, dan Vercel memakai HMAC-SHA1, sedangkan Discord, SendGrid, dan PayPal memakai tanda tangan kunci publik. Di antara yang memakai HMAC, perbedaannya ada pada encoding (hex vs base64) dan ada tidaknya stempel waktu, dan setiap skema yang sudah kami implementasikan dirangkum dalam tabel di referensi provider webhook. Jika Anda ingin memverifikasi satu header, bukan tiga, arahkan provider Anda ke relay. AnyHook menangani fan-in (beberapa sumber ke satu tujuan) dan menandatangani ulang setiap pengiriman keluar dengan satu skema yang konsisten.