Keamanan18 April 20269 mnt baca

Verifikasi Tanda Tangan (Signature) Webhook Stripe, GitHub, dan Shopify

Verifikasi tanda tangan (signature) webhook lintas provider: yang sering salah, cara kerja toleransi timestamp, dan kenapa retry merusak implementasi naif.

TIGA TANDA TANGAN, SATU YANG HARUS ANDA VERIFIKASIprovidermenandatangani raw bodyStripe-Signatureedgeverifikasi, simpantanda tangan ulangsetiap upayastempel waktu baruAndaheader provider yang diteruskan membawa stempel waktu aslinya, jadi kedaluwarsa saat dicoba ulangAnyHook-Signature ditandatangani ulang di setiap upaya, jadi toleransi 5 menit selalu terpenuhi
Ada tiga tanda tangan. Handler Anda seharusnya cukup memeriksa satu.

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 t dan tanda tangan v1 yang di-encode hex di Stripe-Signature, dengan toleransi stempel waktu 5 menit.
  • GitHub mengirim sha256=… di X-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:

  1. Ambil t dan semua nilai v1 dari header.
  2. Tolak jika t lebih lama dari sekitar 5 menit (perlindungan terhadap serangan replay).
  3. Hitung HMAC_SHA256(signing_secret, t + "." + raw_body).
  4. 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));
}
HMAC DIHITUNG DARI BYTE, BUKAN DARI MAKNAbyte dari jaringan{"id":"evt_1","n":2}req.text()hmaccocokparse + stringify ulang{ "n": 2, "id": "evt_1" }req.json()hmactidak cocokdatanya samadatanya sama. urutan key berbeda,spasi berbeda, dan payload-nyatampak sempurna di log Anda.
Kedua objek adalah event yang sama. Hanya salah satunya yang hash-nya masih cocok dengan tanda tangan yang dikirim provider.

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.

Semua artikel18 April 2026 · 9 mnt

Berhenti kehilangan webhook.

Ganti satu URL. Dapatkan percobaan ulang, log event, dan putar ulang sekali klik. Atau lihat dulu: daftar dan jalankan latihan gangguan 30 detik, lalu saksikan event tetap selamat saat server Anda mati. Tidak perlu server.