Verifikasi tanda tangan webhook5 menit baca
Verifikasi tanda tangan webhook di Go
Verifikasi tanda tangan webhook Ikiform di Go dengan net/http: baca body sekali, periksa dengan crypto/hmac, uji, atau gunakan library Standard Webhooks.
Baca seluruh body dengan io.ReadAll di balik http.MaxBytesReader, lalu hitung HMAC-SHA256 dari id.timestamp.body dengan secret whsec_ hasil decode base64 dan bandingkan dengan setiap tanda tangan v1 memakai hmac.Equal. Pastikan timestamp dalam 5 menit, dan baru setelah itu json.Unmarshal byte yang sama. Library Go Standard Webhooks melakukan hal yang sama dalam satu panggilan Verify.
Standard library Go punya semua yang dibutuhkan pemeriksaan ini, jadi halaman ini tidak memakai framework atau dependency sampai library opsional di bagian akhir. Handler-nya net/http biasa dengan pola method-dan-path yang ditambahkan di Go 1.22, dan function yang sama bisa dipakai di chi, Gin, atau Echo tanpa perubahan.
Apa yang berbeda di Go
Tidak ada bagian net/http yang mem-parsing body untuk Anda, jadi jebakan body mentah yang biasa tidak ada. Jebakan khas Go adalah stream itu sendiri: r.Body hanya bisa dibaca sekali. json.NewDecoder(r.Body).Decode(&event) yang idiomatis menghabiskannya, dan tidak ada yang tersisa untuk diverifikasi. Jadi baca byte-nya ke memori lebih dulu, dengan batas agar post besar tidak menghabiskan memori, dan gunakan satu slice itu untuk kedua tugas.
- MaxBytesReader membungkus body agar pembacaan berhenti setelah 300 KB, di atas batas payload Ikiform 256 KB.
- io.ReadAll membaca seluruh body ke satu byte slice.
- VerifySignature memeriksa header, timestamp, dan setiap tanda tangan v1 terhadap byte itu.
- Jika berhasil, json.Unmarshal mem-parsing slice yang sama ke dalam struct.
- Handler menjawab 204. Pada error verifikasi apa pun, handler menjawab 400 beserta alasannya.
Function verifikasi
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"errors"
"net/http"
"strconv"
"strings"
"time"
)
const tolerance = 5 * time.Minute
var (
ErrMissingHeaders = errors.New("missing_headers")
ErrInvalidTimestamp = errors.New("invalid_timestamp")
ErrTooOld = errors.New("timestamp_too_old")
ErrTooNew = errors.New("timestamp_too_new")
ErrNoMatch = errors.New("no_matching_signature")
)
func VerifySignature(body []byte, header http.Header, key []byte, now time.Time) error {
id := header.Get("webhook-id")
timestamp := header.Get("webhook-timestamp")
signatures := header.Get("webhook-signature")
if id == "" || timestamp == "" || signatures == "" {
return ErrMissingHeaders
}
seconds, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
return ErrInvalidTimestamp
}
age := now.Sub(time.Unix(seconds, 0))
if age > tolerance {
return ErrTooOld
}
if age < -tolerance {
return ErrTooNew
}
mac := hmac.New(sha256.New, key)
mac.Write([]byte(id + "." + timestamp + "."))
mac.Write(body)
expected := mac.Sum(nil)
for _, entry := range strings.Split(signatures, " ") {
version, value, found := strings.Cut(entry, ",")
if !found || version != "v1" {
continue
}
received, err := base64.StdEncoding.DecodeString(value)
if err == nil && hmac.Equal(received, expected) {
return nil
}
}
return ErrNoMatch
}
func DecodeSecret(secret string) ([]byte, error) {
return base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_"))
}header.Getmenormalkan nama, jadiwebhook-idmenemukanWebhook-Idbagaimanapun request menuliskannya.hmac.Equalbersifat constant-time dan mengembalikanfalseuntuk panjang yang berbeda, jadi tanda tangan yang cacat cukup dianggap tidak cocok.strings.Cuthanya memisahkan di koma pertama, sesuai yang diminta spesifikasi.base64.StdEncodingadalah alfabet standar dengan padding.RawStdEncodingatauURLEncodingakan menolak atau salah men-decode secret maupun tanda tangan.- Key di-decode sekali saat startup oleh
DecodeSecret, bukan di setiap request.
Handler-nya
package main
import (
"encoding/json"
"errors"
"io"
"log"
"net/http"
"os"
"time"
)
type Event struct {
ID string `json:"id"`
Type string `json:"type"`
Data struct {
SubmissionID string `json:"submission_id"`
Fields map[string]any `json:"fields"`
} `json:"data"`
}
func main() {
key, err := DecodeSecret(os.Getenv("IKIFORM_WEBHOOK_SECRET"))
if err != nil || len(key) == 0 {
log.Fatal("IKIFORM_WEBHOOK_SECRET must be the whsec_ secret from the dashboard")
}
mux := http.NewServeMux()
mux.HandleFunc("POST /webhooks/ikiform", func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 300<<10))
if err != nil {
http.Error(w, "body too large", http.StatusRequestEntityTooLarge)
return
}
if err := VerifySignature(body, r.Header, key, time.Now()); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
var event Event
if err := json.Unmarshal(body, &event); err != nil {
http.Error(w, "invalid_payload", http.StatusBadRequest)
return
}
if event.Type == "submission.created" {
log.Printf("submission %s %v", event.Data.SubmissionID, event.Data.Fields)
}
w.WriteHeader(http.StatusNoContent)
})
server := &http.Server{
Addr: ":3000",
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
}
if err := server.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
log.Fatal(err)
}
}ReadHeaderTimeout melindungi server dari klien yang lambat; http.ListenAndServe polos sama sekali tidak punya timeout. Struct hanya menyimpan field yang Anda pakai. json.Unmarshal mengabaikan sisanya, jadi field payload baru tidak pernah merusaknya. Di balik reverse proxy seperti nginx, pertahankan batas body-nya, client_max_body_size, di atas 256 KB juga, atau proxy menolak kiriman besar sebelum Go melihatnya. Body 400 membawa teks error, seperti no_matching_signature, dan log pengiriman menampilkan 4 KB pertama dari setiap respons, jadi kegagalan menjelaskan dirinya sendiri di sana.
c.GetRawData(); di Echo, io.ReadAll(c.Request().Body); di chi, handler di atas langsung berfungsi. Jangan panggil c.ShouldBindJSON atau c.Bind sebelum verifikasi, karena alasan sekali-baca yang sama.Table test
package main
import (
"bytes"
"encoding/json"
"errors"
"net/http"
"testing"
"time"
)
const (
secret = "whsec_Fg8c6hm1Gt41v5RSPKm72LggDx8S1mlWkbBYpHPAnpw="
rolled = "whsec_4YWiO+F83kohh/SGNr2zcgChkpVJ65ySVeV23Qabr2A="
body = `{"id":"dlv_01k7v2d4n8r2p6s0w3y5b7c9e1","type":"endpoint.test","timestamp":"2026-10-17T12:00:00.000Z","api_version":"2026-10-17","data":{"endpoint_id":"whk_01k7v2e1b3d5f7h9k1m3n5q7s9","message":"This is a test event from Ikiform."}}`
)
func vectorHeader(signature string) http.Header {
header := http.Header{}
header.Set("webhook-id", "dlv_01k7v2d4n8r2p6s0w3y5b7c9e1")
header.Set("webhook-timestamp", "1792300000")
header.Set("webhook-signature", signature)
return header
}
var now = time.Unix(1792300000, 0).Add(30 * time.Second)
func TestVerifySignature(t *testing.T) {
key, _ := DecodeSecret(secret)
rolledKey, _ := DecodeSecret(rolled)
single := vectorHeader("v1,Duq+Iuh0xbRZyS+C/TO5W1hcGLGwg+ifBiWiYkq15qA=")
both := vectorHeader("v1,Duq+Iuh0xbRZyS+C/TO5W1hcGLGwg+ifBiWiYkq15qA= v1,gfHP3s7ANdIpnM7gewtWtSvTrHgmsbEFgCaeRRR0Eak=")
var pretty bytes.Buffer
if err := json.Indent(&pretty, []byte(body), "", " "); err != nil {
t.Fatal(err)
}
cases := []struct {
name string
body []byte
header http.Header
key []byte
now time.Time
want error
}{
{"vector", []byte(body), single, key, now, nil},
{"old secret during a roll", []byte(body), both, key, now, nil},
{"new secret during a roll", []byte(body), both, rolledKey, now, nil},
{"re-serialized body", pretty.Bytes(), single, key, now, ErrNoMatch},
{"stale timestamp", []byte(body), single, key, now.Add(10 * time.Minute), ErrTooOld},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := VerifySignature(c.body, c.header, c.key, c.now); !errors.Is(got, c.want) {
t.Fatalf("got %v, want %v", got, c.want)
}
})
}
}Jalankan go test ./.... Kasus-kasusnya berasal dari test vector. json.Indent menghasilkan body serialisasi ulang yang mungkin muncul di codebase Go sungguhan, dan now yang tetap menjaga timestamp vector di dalam jendela.
Pengulangan dan pekerjaan lambat
Dua hal terjadi setelah pengiriman terverifikasi. Pertama, lewati jika sudah pernah Anda lihat: Ikiform mencoba ulang dengan webhook-id yang sama saat tidak mendapat 2xx dalam 15 detik, meski handler Anda sudah selesai. Dengan PostgreSQL dan kolom unik, RowsAffected memberi tahu apakah ini yang pertama:
func firstDelivery(ctx context.Context, db *sql.DB, deliveryID string) (bool, error) {
result, err := db.ExecContext(ctx,
"INSERT INTO webhook_deliveries (delivery_id) VALUES ($1) ON CONFLICT (delivery_id) DO NOTHING",
deliveryID,
)
if err != nil {
return false, err
}
rows, err := result.RowsAffected()
return rows == 1, err
}Kedua, jawab sebelum pekerjaan lambat. Goroutine adalah alat yang jelas, dengan satu jebakan khas Go: r.Context() dibatalkan begitu handler kembali, jadi panggilan database atau request HTTP apa pun yang dibuat dengannya di latar belakang gagal dengan context canceled. Beri goroutine context-nya sendiri:
go func(event Event) {
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
if err := forward(ctx, event); err != nil {
log.Printf("forward %s: %v", event.ID, err)
}
}(event)
w.WriteHeader(http.StatusNoContent)Goroutine mati bersama prosesnya, jadi deploy di tengah pekerjaan akan menghilangkannya. Untuk apa pun yang tidak boleh hilang, tulis event ke antrean atau tabel di dalam handler, jawab 204, dan proses dari sana.
Dengan library Standard Webhooks
Library Go dari spesifikasi ini ada di modul libraries di repositorinya. Tambahkan dengan go get github.com/standard-webhooks/standard-webhooks/libraries:
package main
import (
"io"
"log"
"net/http"
"os"
standardwebhooks "github.com/standard-webhooks/standard-webhooks/libraries/go"
)
func main() {
webhook, err := standardwebhooks.NewWebhook(os.Getenv("IKIFORM_WEBHOOK_SECRET"))
if err != nil {
log.Fatal(err)
}
http.HandleFunc("POST /webhooks/ikiform", func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 300<<10))
if err != nil {
http.Error(w, "body too large", http.StatusRequestEntityTooLarge)
return
}
if err := webhook.Verify(body, r.Header); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusNoContent)
})
log.Fatal(http.ListenAndServe(":3000", nil))
}NewWebhook menghapus whsec_ dan men-decode secret. Verify menerima body dan r.Header secara langsung dan mengembalikan error yang membungkus ErrNoMatchingSignature, ErrMessageTooOld, dan lainnya, jadi Anda bisa membedakannya dengan errors.Is. Ada juga VerifyIgnoringTimestamp; jangan memakainya di produksi, karena membuka kembali celah replay.
Pastikan berfungsi
- Jalankan
go test ./.... Kelima kasus lolos. - Jalankan server di balik URL
https:publik dan tambahkan di Webhooks di dashboard. - Ekspor
IKIFORM_WEBHOOK_SECRETdengan secretwhsec_milik endpoint. Server menolak berjalan tanpa secret yang valid. - Pilih Send test. Log pengiriman menampilkan
204. - Kirim request dengan curl dan tanda tangan karangan. Request itu mendapat
400 no_matching_signature.
Hal yang sering menjebak
| Masalah | Penyebab dan solusi |
|---|---|
| Body kosong saat Anda memverifikasi | Ada yang membaca r.Body lebih dulu, sering kali decoder atau middleware logging. Baca sekali dan teruskan slice-nya |
illegal base64 data saat startup | Secret di-decode dengan RawStdEncoding, atau disalin tanpa = di akhirnya. Gunakan StdEncoding dan nilai lengkapnya |
404 di path webhook | Pola seperti POST /webhooks/ikiform butuh Go 1.22 atau lebih baru dan baris go 1.22 atau lebih baru di go.mod; pengaturan lama memperlakukan pola itu sebagai path literal |
413 di log pengiriman | Batas MaxBytesReader di bawah 256 KB. Pertahankan di atas batas payload Ikiform |
Test vector gagal dengan timestamp_too_old | Test memakai time.Now(). Berikan now yang tetap dari test, seperti di atas |
Pertanyaan
Gunakan hmac.Equal dari crypto/hmac. Fungsi ini membandingkan secara constant-time dan mengembalikan false saat panjangnya berbeda. Jangan pakai bytes.Equal atau ==, yang bisa langsung kembali begitu satu byte berbeda.
Body request adalah stream dan hanya bisa dibaca sekali. Baca ke byte slice dengan io.ReadAll, verifikasi slice itu, lalu json.Unmarshal slice yang sama.
Ada, di repositori standard-webhooks di bawah libraries/go. NewWebhook(secret) menerima secret whsec_, dan Verify(body, r.Header) mengembalikan error saat pemeriksaan gagal.
Tidak perlu. hmac.Equal memanggil subtle.ConstantTimeCompare untuk Anda dan merupakan cara terdokumentasi untuk membandingkan MAC. Keduanya constant-time; == dan bytes.Equal tidak.
Mentok di salah satu langkah, atau ingin panduan untuk situasi Anda? Email hello@ikiform.com.