Webhook-Signaturen prüfen5 Min. Lesezeit
Webhook-Signaturen in Go verifizieren
Verifiziere Ikiform-Webhook-Signaturen in Go mit net/http: Lies den Body einmal, prüf ihn mit crypto/hmac, teste ihn oder nutze die Standard-Webhooks-Bibliothek.
Lies den ganzen Body mit io.ReadAll hinter http.MaxBytesReader, berechne dann HMAC-SHA256 über id.timestamp.body mit dem base64-dekodierten whsec_-Secret und vergleiche es mit hmac.Equal mit jeder v1-Signatur. Prüfe, dass der Zeitstempel innerhalb von 5 Minuten liegt, und erst dann json.Unmarshal dieselben Bytes. Die Go-Bibliothek von Standard Webhooks tut dasselbe in einem Verify-Aufruf.
Die Standardbibliothek von Go hat alles, was die Prüfung braucht, also nutzt diese Seite bis zur optionalen Bibliothek am Ende kein Framework und keine Abhängigkeit. Der Handler ist einfaches net/http mit den Methoden-und-Pfad-Patterns aus Go 1.22, und dieselbe Funktion passt unverändert in chi, Gin oder Echo.
Was in Go anders ist
Nichts in net/http parst den Body für dich, was die übliche Falle mit dem rohen Body beseitigt. Die Go-spezifische Falle ist der Stream selbst: r.Body lässt sich einmal lesen. Das idiomatische json.NewDecoder(r.Body).Decode(&event) verbraucht ihn, und es bleibt nichts zum Verifizieren. Lies die Bytes also zuerst in den Speicher, begrenzt, damit ein großer Post ihn nicht erschöpfen kann, und nutze diesen einen Slice für beide Aufgaben.
- MaxBytesReader umhüllt den Body, sodass das Lesen über 300 KB stoppt, oberhalb der Payload-Grenze von 256 KB bei Ikiform.
- io.ReadAll liest den ganzen Body in einen Byte-Slice.
- VerifySignature prüft die Header, den Zeitstempel und jede v1-Signatur gegen diese Bytes.
- Bei Erfolg parst json.Unmarshal denselben Slice in ein Struct.
- Der Handler antwortet mit 204. Bei jedem Verifizierungsfehler antwortet er mit 400 und dem Grund.
Die Verify-Funktion
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.Getkanonisiert den Namen, sodasswebhook-idauchWebhook-Idfindet, egal wie der Request ihn geschrieben hat.hmac.Equalarbeitet in konstanter Zeit und gibt bei unterschiedlichen Längenfalsezurück, also ist eine fehlerhafte Signatur einfach ein Nicht-Treffer.strings.Cuttrennt nur am ersten Komma, genau wie es die Spezifikation verlangt.base64.StdEncodingist das gepaddete Standardalphabet.RawStdEncodingoderURLEncodingwürden sowohl das Secret als auch die Signaturen ablehnen oder falsch dekodieren.- Der Key wird einmal beim Start von
DecodeSecretdekodiert, nicht bei jedem Request.
Der Handler
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 schützt den Server vor langsamen Clients; das nackte http.ListenAndServe hat überhaupt keine Timeouts. Das Struct behält nur die Felder, die du nutzt. json.Unmarshal ignoriert den Rest, sodass neue Payload-Felder es nie brechen. Hinter einem Reverse Proxy wie nginx halte auch dessen Body-Limit, client_max_body_size, über 256 KB, sonst lehnt der Proxy große Einsendungen ab, bevor Go sie sieht. Der 400-Body enthält den Text des Fehlers, etwa no_matching_signature, und das Zustellungsprotokoll zeigt die ersten 4 KB jeder Antwort, sodass sich ein Fehler dort selbst erklärt.
c.GetRawData(), in Echo mit io.ReadAll(c.Request().Body); in chi funktioniert der Handler oben unverändert. Ruf vor dem Verifizieren weder c.ShouldBindJSON noch c.Bind auf, aus demselben Grund des einmaligen Lesens.Ein Tabellentest
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)
}
})
}
}Führe go test ./... aus. Die Fälle stammen aus dem Testvektor. json.Indent liefert den neu serialisierten Body, den eine echte Go-Codebasis erzeugen könnte, und das feste now hält den Zeitstempel des Testvektors im Fenster.
Wiederholungen und langsame Arbeit
Nach dem Verifizieren einer Zustellung passieren zwei Dinge. Erstens: Überspringe sie, wenn du sie schon gesehen hast. Ikiform wiederholt mit derselben webhook-id, wenn es innerhalb von 15 Sekunden kein 2xx bekommt, selbst wenn dein Handler fertig war. Mit PostgreSQL und einer eindeutigen Spalte sagt dir RowsAffected, ob es das erste Mal ist:
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
}Zweitens: Antworte vor der langsamen Arbeit. Eine Goroutine ist das naheliegende Werkzeug, mit einer Go-spezifischen Falle: r.Context() wird abgebrochen, sobald der Handler zurückkehrt, also scheitert jeder Datenbankaufruf oder HTTP-Request, der damit im Hintergrund läuft, mit context canceled. Gib der Goroutine ihren eigenen Context:
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)Eine Goroutine stirbt mit dem Prozess, also geht die Arbeit bei einem Deploy mittendrin verloren. Für alles, was du nicht verlieren darfst, schreib das Event im Handler in eine Queue oder Tabelle, antworte mit 204 und verarbeite es von dort.
Mit der Standard-Webhooks-Bibliothek
Die Go-Bibliothek der Spezifikation liegt im Modul libraries ihres Repositorys. Füge sie mit go get github.com/standard-webhooks/standard-webhooks/libraries hinzu:
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 entfernt whsec_ und dekodiert das Secret. Verify nimmt den Body und r.Header direkt und gibt einen Fehler zurück, der ErrNoMatchingSignature, ErrMessageTooOld und die anderen umhüllt, sodass du sie mit errors.Is unterscheiden kannst. Es gibt auch VerifyIgnoringTimestamp; nutze es nicht in Produktion, denn es öffnet Replays wieder.
Prüfen, ob es funktioniert
- Führe
go test ./...aus. Alle fünf Fälle bestehen. - Starte den Server hinter einer öffentlichen
https:-URL und füge sie im Dashboard unter Webhooks hinzu. - Exportiere
IKIFORM_WEBHOOK_SECRETmit demwhsec_-Secret des Endpunkts. Ohne gültiges Secret startet der Server nicht. - Wähle Send test. Das Zustellungsprotokoll zeigt
204. - Sende einen Request mit curl und einer erfundenen Signatur. Er bekommt
400 no_matching_signature.
Stolperfallen
| Problem | Ursache und Lösung |
|---|---|
| Der Body ist beim Verifizieren leer | Etwas hat r.Body zuerst gelesen, oft ein Decoder oder eine Logging-Middleware. Lies ihn einmal und reich den Slice weiter |
illegal base64 data beim Start | Das Secret wurde mit RawStdEncoding dekodiert oder ohne sein abschließendes = kopiert. Nutze StdEncoding und den vollständigen Wert |
404 auf dem Webhook-Pfad | Patterns wie POST /webhooks/ikiform brauchen Go 1.22 oder neuer und eine Zeile go 1.22 oder neuer in go.mod; ältere Einstellungen behandeln das Pattern als wörtlichen Pfad |
413 im Zustellungsprotokoll | Das Limit von MaxBytesReader liegt unter 256 KB. Halte es über der Payload-Grenze von Ikiform |
Der Testvektor scheitert mit timestamp_too_old | Der Test hat time.Now() genutzt. Übergib wie oben das feste now aus dem Test |
Fragen
Nutze hmac.Equal aus crypto/hmac. Es vergleicht in konstanter Zeit und gibt bei unterschiedlichen Längen false zurück. Nutze nicht bytes.Equal oder ==, die zurückkehren können, sobald ein Byte abweicht.
Der Request-Body ist ein Stream und lässt sich einmal lesen. Lies ihn mit io.ReadAll in einen Byte-Slice, verifiziere den Slice und dann json.Unmarshal denselben Slice.
Ja, im Repository standard-webhooks unter libraries/go. NewWebhook(secret) nimmt das whsec_-Secret, und Verify(body, r.Header) gibt einen Fehler zurück, wenn eine Prüfung scheitert.
Das ist nicht nötig. hmac.Equal ruft subtle.ConstantTimeCompare für dich auf und ist der dokumentierte Weg, MACs zu vergleichen. Beide arbeiten in konstanter Zeit; == und bytes.Equal nicht.
Hängst du bei einem Schritt fest oder willst eine Anleitung für deinen Fall? Schreib an hello@ikiform.com.