Blog · Terbit
Merotasi signing secret webhook tanpa kehilangan satu pun event: jendela 24 jam saat header membawa dua tanda tangan sekaligus, verifikasi yang hanya membaca entri pertama lalu gagal tanpa suara, dan ekor percobaan ulang yang lebih panjang daripada jendelanya
Merotasi signing secret webhook adalah satu klik di dashboard, dan yang patah sesudahnya hampir tidak pernah klik itu. Yang patah adalah kode verifikasi di ujung sana, dan bentuk kegagalannya adalah yang paling mahal: pembayaran tetap masuk, pembeli tetap membayar, dan pemberitahuan yang mengatakan begitu ditolak satu per satu oleh server sendiri tanpa satu pun pesan galat yang terbaca sebagai masalah.
Jawaban singkatnya, supaya tidak perlu membaca seluruh halaman ini sebelum merotasi: selama 24 jam setelah rotasi, setiap pengiriman ditandatangani dua kali, satu per secret, jadi tidak ada yang perlu dipasang berdampingan di sisi penerima dan tidak ada urutan deploy yang harus dikejar dalam hitungan menit. Syaratnya dua, dan keduanya soal kode verifikasi yang sudah berjalan hari ini: kode itu harus memeriksa setiap entri tanda tangan, bukan entri pertama saja, dan harus sudah memakai header bertimestamp, bukan header lama. Yang tidak memenuhi salah satunya kehilangan event mulai detik rotasi dijalankan.
Yang sebenarnya dibawa setiap pengiriman
Setiap pengiriman webhook membawa dua header tanda tangan sekaligus, dan hanya salah satunya yang punya masa tenggang saat secret berganti.
Kasera-Signature-V1, berbentukt=<unix>,v1=<hex>, dengan setiapv1adalah HMAC-SHA256 atast + "." + rawBody. Timestamp ikut ditandatangani, jadi pengiriman yang disadap tidak bisa diputar ulang belakangan, dan pengiriman yang timestamp-nya melenceng lebih dari lima menit dari jam server penerima memang seharusnya ditolak.Kasera-Signature, header lama: hex HMAC-SHA256 atas raw body, tanpa timestamp. Statusnya deprecated dan akan dihapus setelah masa pemberitahuan yang diumumkan lebih dulu.Kasera-Event-Id, yang bukan tanda tangan tetapi menempel pada persoalan yang sama: id event bisa dibaca sebelum body-nya diurai, sehingga event yang sudah pernah diproses bisa dikenali tanpa memverifikasi apa pun.
Perbedaan yang menentukan pada hari rotasi ada di kalimat terakhir dokumentasi header lama: header itu ditandatangani dengan secret yang berlaku saat itu saja, tanpa masa tenggang rotasi sama sekali. Alasannya bukan kebijakan melainkan bentuk: nilainya satu hex telanjang, dan satu nilai tidak bisa menawarkan dua kemungkinan. Verifikasi yang masih bergantung padanya patah pada pengiriman pertama sesudah rotasi, tanpa jendela, tanpa peringatan. Bentuk lengkap kedua header beserta kode verifikasinya ada di referensi webhook.
Jendela 24 jam, dan urutan yang menipu di dalamnya
Rotasi tidak membuang secret lama. Secret lama disimpan bersama tenggat 24 jam ke depan, dan selama tenggat itu belum lewat, setiap pengiriman membawa dua entri v1 di dalam satu header Kasera-Signature-V1, satu per secret. Pengiriman dinyatakan sah kalau salah satu entri cocok. Praktisnya: handler yang masih memegang secret lama tetap lolos, handler yang sudah memegang secret baru juga lolos, dan deploy bisa dikerjakan pada jam kerja berikutnya alih-alih pada menit itu juga.
Yang menipu adalah urutannya. Entri pertama adalah milik secret baru, dan entri kedua milik secret lama. Handler yang belum di-deploy ulang, yang karena itu masih memegang secret lama, akan menemukan kecocokannya di entri kedua. Jendela 24 jam itu hanya melindungi verifikasi yang benar-benar memeriksa seluruh entri.
Tiga verifikasi yang patah pada rotasi, dan tak satu pun melempar galat
Ketiganya berjalan sempurna bertahun-tahun selama secret tidak pernah berganti, yang membuat rotasi pertama menjadi satu-satunya saat ketiganya ketahuan.
- Yang hanya membaca entri pertama. Bentuk yang paling umum, karena sebelum ada rotasi memang hanya ada satu entri dan
parts[1]selalu benar. Sesudah rotasi, handler yang masih memegang secret lama membandingkan secret lama dengan tanda tangan secret baru dan menolak setiap pengiriman. Tidak ada galat, tidak ada pengecualian: hanya angka penolakan yang naik. - Yang masih memakai header lama. Tidak ada entri kedua untuk diperiksa sama sekali, jadi jendelanya tidak pernah berlaku. Patah pada pengiriman pertama sesudah rotasi, juga tanpa suara.
- Yang memverifikasi atas body yang sudah diurai ulang. Tidak berhubungan dengan rotasi, tetapi ikut ketahuan pada hari yang sama karena hari itulah kode verifikasi dibuka kembali. Tanda tangan dihitung atas byte mentah, dan JSON yang diurai lalu dirangkai ulang menghasilkan byte lain yang tidak akan pernah cocok. Kesalahan ini dibahas terpisah di panduan mengamankan webhook pembayaran.
// Yang membedakan verifikasi yang selamat dari rotasi
// dan yang tidak, pada baris terakhir.
const parts = v1Header.split(","); // "t=1723350300,v1=5f4d...,v1=9a1b..."
// Patah pada rotasi: hanya entri pertama yang diperiksa.
const sig = parts[1].slice(3);
return timingSafeEqualHex(sig, expected);
// Selamat: diterima kalau SALAH SATU entri cocok.
return parts
.slice(1)
.filter((p) => p.startsWith("v1="))
.some((p) => timingSafeEqualHex(p.slice(3), expected));Perbandingannya sendiri tetap harus memakai perbandingan yang waktunya konstan, dan pemeriksaan toleransi timestamp lima menit tetap dikerjakan sebelum entri mana pun dibandingkan.
Harga sebuah penolakan: tujuh percobaan, lalu tidak ada lagi
Jawaban selain 2xx tidak membuat event hilang seketika. Event masuk ke tangga percobaan ulang dengan jeda yang melebar: satu menit, lima menit, tiga puluh menit, dua jam, enam jam, dua puluh empat jam. Tujuh percobaan termasuk yang pertama, dan ekornya sekitar 33 jam sejak percobaan pertama. Sesudah percobaan terakhir event itu habis, dan satu-satunya yang bisa menghidupkannya kembali adalah tombol kirim ulang di dashboard, satu per satu.
Artinya sebuah rotasi yang salah pada Jumat sore tidak berbunyi sampai Senin. Sepanjang akhir pekan pembayaran tetap masuk, setiap pemberitahuannya ditolak tujuh kali, dan yang paling awal sudah kedaluwarsa sebelum ada orang yang membaca grafik apa pun. Cara membedakan event yang gagal terkirim dari event yang memang tidak pernah dikirim ada di tulisan tentang webhook yang tidak sampai.
Ekornya lebih panjang daripada jendelanya
Dua angka di atas tidak sama panjang, dan selisihnya adalah satu-satunya jebakan yang tidak bisa diperbaiki dari sisi penerima: jendela tanda tangan gandanya 24 jam, sementara ekor percobaan ulangnya sekitar 33 jam. Tanda tangan dihitung ulang pada setiap percobaan, bukan disimpan dari percobaan pertama, jadi event yang lahir sebelum rotasi dan masih diulang pada jam ke-30 dikirim dengan tanda tangan secret baru saja.
Konsekuensinya satu kalimat: memasang secret baru “nanti saja” aman selama masih di dalam 24 jam, dan berhenti aman tepat sesudahnya, termasuk bagi event yang sudah lama beredar. Menyimpan secret lama berdampingan di dalam kode tidak memperpanjang apa pun, karena yang berhenti adalah sisi pengirim.
Urutan yang tidak kehilangan apa pun
- Pastikan verifikasi sudah memakai
Kasera-Signature-V1dan sudah memeriksa setiap entriv1. Dikerjakan sebelum rotasi, bukan sesudahnya, dan ini satu-satunya langkah yang benar-benar tidak boleh ditukar urutannya. - Rotasi secret di dashboard. Sejak detik ini jendela 24 jam berjalan, dan endpoint tetap menerima pengiriman tanpa perubahan apa pun di sisi penerima.
- Ganti nilai secret di variabel lingkungan lalu deploy. Tidak perlu variabel kedua, tidak perlu dua nama variabel, dan tidak perlu jendela pemeliharaan.
- Buktikan dengan pengiriman sungguhan sebelum jendelanya habis, bukan dengan membaca kode: kirim ulang satu event dari dashboard, atau pakai tombol uji endpoint, lalu pastikan jawabannya 2xx. Tombol uji melewati jalur pengiriman yang sama persis dengan pembayaran sungguhan, header dan tanda tangannya identik.
- Periksa lagi keesokan harinya, sesudah 24 jam lewat, karena sejak saat itu hanya secret baru yang menandatangani dan kesalahan yang selama ini tertutup jendela akan muncul di situ.
Merotasi dua kali di dalam jendela menghapus secret tertua
Refleks yang wajar saat rotasi terasa gagal adalah merotasi sekali lagi. Pada kasus ini refleks itu memperburuk keadaan. Yang menandatangani tidak pernah lebih dari dua secret, jadi rotasi kedua di dalam jendela membuang yang tertua, yaitu satu-satunya secret yang masih dipegang handler yang belum di-deploy. Rotasi pertama menyisakan jalan pulang; rotasi kedua menutupnya.
Kalau rotasi sudah terlanjur dijalankan dan kode verifikasi ternyata hanya membaca entri pertama, yang benar adalah memperbaiki kode itu lalu deploy di dalam sisa jendela. Selama itu berlangsung, event yang tertolak masih berada di tangga percobaan ulang dan sebagian besar akan masuk sendiri begitu handler-nya benar.
Yang tidak menuntut rotasi sama sekali
Dua keadaan sering disangka menuntut rotasi, padahal tidak. Memindahkan URL webhook ke domain baru tidak mengganti secret, jadi migrasi domain tidak menuntut deploy ulang kode verifikasi. Dan sebuah endpoint bisa dibuat tanpa URL lebih dulu, hanya untuk mendapatkan secret-nya, sehingga handler bisa dibangun dan di-deploy sebelum domain mana pun diarahkan.
Satu hal lagi yang bukan rotasi: mode tes dan mode live adalah endpoint terpisah dengan secret masing-masing, dan secret tes tidak akan pernah memverifikasi payload live. Merotasi yang satu tidak menyentuh yang lain. Sebuah akun boleh memasang sampai lima endpoint per mode, masing-masing dengan secret sendiri, jadi rotasi selalu berlaku untuk satu endpoint saja, bukan untuk akun.
Kapan signing secret perlu dirotasi
Rotasi terjadwal tanpa sebab tidak banyak gunanya di sini: setiap rotasi adalah kesempatan kehilangan event, dan yang dipegang penyerang yang membaca signing secret hanyalah kemampuan memalsukan pemberitahuan, bukan kemampuan memindahkan uang. Yang benar-benar menuntut rotasi adalah kebocoran nilainya, yaitu secret yang pernah masuk ke repositori, ke log, ke tangkapan layar, atau ke pihak ketiga yang berhenti dipakai. Aturan yang sama untuk API key beserta tiga keadaan kehilangan kunci ada di tulisan tentang menyimpan dan merotasi kunci API, dan bentuk handler yang siap menerima keduanya ada di panduan integrasi Laravel.
Ringkasnya
- Sesudah rotasi,
Kasera-Signature-V1membawa dua entriv1selama 24 jam, dan entri pertama milik secret baru. - Verifikasi harus menerima kalau salah satu entri cocok; yang membaca
parts[1]saja menolak semuanya tanpa satu pun galat. - Header lama
Kasera-Signaturetidak punya masa tenggang dan patah pada pengiriman pertama sesudah rotasi. - Ekor percobaan ulang sekitar 33 jam lebih panjang daripada jendela 24 jam, jadi menunda deploy melewati jendela tetap kehilangan event.
- Merotasi dua kali di dalam jendela membuang secret tertua, yaitu satu-satunya yang masih dipegang handler lama.