Webhook-Signaturen prüfen

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.

Preet SutharGründer, Ikiform

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.

Die Reihenfolge der Schritte in einem Go-Webhook-HandlerMaxBytesReaderGrenze300 KBio.ReadAllein []byteVerifySignaturehmac.Equalpro v1json.UnmarshaldieselbenBytes204400err.Error()als GrundFehlerDie Reihenfolge der Schritte in einem Go-Webhook-HandlerMaxBytesReaderGrenze 300 KBio.ReadAllein []byteVerifySignaturehmac.Equal pro v1json.Unmarshaldieselben Bytes204400err.Error()als GrundFehler
  1. MaxBytesReader umhüllt den Body, sodass das Lesen über 300 KB stoppt, oberhalb der Payload-Grenze von 256 KB bei Ikiform.
  2. io.ReadAll liest den ganzen Body in einen Byte-Slice.
  3. VerifySignature prüft die Header, den Zeitstempel und jede v1-Signatur gegen diese Bytes.
  4. Bei Erfolg parst json.Unmarshal denselben Slice in ein Struct.
  5. Der Handler antwortet mit 204. Bei jedem Verifizierungsfehler antwortet er mit 400 und dem Grund.
Ein json.Decoder auf r.Body würde den zweiten Schritt überspringen und nichts zum Verifizieren übrig lassen.

Die Verify-Funktion

signature.go
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.Get kanonisiert den Namen, sodass webhook-id auch Webhook-Id findet, egal wie der Request ihn geschrieben hat.
  • hmac.Equal arbeitet in konstanter Zeit und gibt bei unterschiedlichen Längen false zurück, also ist eine fehlerhafte Signatur einfach ein Nicht-Treffer.
  • strings.Cut trennt nur am ersten Komma, genau wie es die Spezifikation verlangt.
  • base64.StdEncoding ist das gepaddete Standardalphabet. RawStdEncoding oder URLEncoding würden sowohl das Secret als auch die Signaturen ablehnen oder falsch dekodieren.
  • Der Key wird einmal beim Start von DecodeSecret dekodiert, nicht bei jedem Request.

Der Handler

main.go
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.

Lies den Body in Gin mit 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

signature_test.go
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:

deliveries.go
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:

main.go
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:

main.go
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

  1. Führe go test ./... aus. Alle fünf Fälle bestehen.
  2. Starte den Server hinter einer öffentlichen https:-URL und füge sie im Dashboard unter Webhooks hinzu.
  3. Exportiere IKIFORM_WEBHOOK_SECRET mit dem whsec_-Secret des Endpunkts. Ohne gültiges Secret startet der Server nicht.
  4. Wähle Send test. Das Zustellungsprotokoll zeigt 204.
  5. Sende einen Request mit curl und einer erfundenen Signatur. Er bekommt 400 no_matching_signature.

Stolperfallen

ProblemUrsache und Lösung
Der Body ist beim Verifizieren leerEtwas hat r.Body zuerst gelesen, oft ein Decoder oder eine Logging-Middleware. Lies ihn einmal und reich den Slice weiter
illegal base64 data beim StartDas Secret wurde mit RawStdEncoding dekodiert oder ohne sein abschließendes = kopiert. Nutze StdEncoding und den vollständigen Wert
404 auf dem Webhook-PfadPatterns 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 ZustellungsprotokollDas Limit von MaxBytesReader liegt unter 256 KB. Halte es über der Payload-Grenze von Ikiform
Der Testvektor scheitert mit timestamp_too_oldDer Test hat time.Now() genutzt. Übergib wie oben das feste now aus dem Test

Fragen

Hängst du bei einem Schritt fest oder willst eine Anleitung für deinen Fall? Schreib an hello@ikiform.com.