# 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.

Veröffentlicht am 2026-10-25 · https://ikiform.com/de/guides/webhook-signature-verification/go

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.

Diagramm: Die Reihenfolge der Schritte in einem Go-Webhook-Handler

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`:

```text
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`:

```text
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`:

```text
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](https://ikiform.com/de/guides/webhook-signature-verification.md#test-vector). `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`:

```text
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`:

```text
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`:

```text
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

| 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

### Wie vergleiche ich HMAC-Signaturen in Go sicher?

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.

### Warum ist r.Body leer, nachdem ich JSON dekodiert habe?

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.

### Gibt es eine Standard-Webhooks-Bibliothek für Go?

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.

### Sollte ich crypto/subtle statt hmac.Equal nutzen?

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.

## Weitere Anleitungen

- [Webhook-Signaturen prüfen](https://ikiform.com/de/guides/webhook-signature-verification.md): Prüfe, ob ein Formular-Webhook wirklich von Ikiform kommt: das HMAC-SHA256-Schema von Standard Webhooks, ein Testvektor, Raw Bodys, Zeitstempel und Secret-Rotation.
- [Node.js](https://ikiform.com/de/guides/webhook-signature-verification/nodejs.md): Prüfe Webhook-Signaturen von Ikiform in Node.js: Raw Bodys in Express und Fastify, das SDK, das Paket standardwebhooks und eine getestete Version mit node:crypto.
- [Python](https://ikiform.com/de/guides/webhook-signature-verification/python.md): Prüfe Webhook-Signaturen von Ikiform in Flask, FastAPI und Django, mit dem Paket standardwebhooks oder der Standardbibliothek, plus einem Test mit pytest.
- [PHP](https://ikiform.com/de/guides/webhook-signature-verification/php.md): Verifiziere Ikiform-Webhook-Signaturen in reinem PHP mit hash_hmac und hash_equals und in Laravel mit der Standard-Webhooks-Bibliothek und einer CSRF-Ausnahme.
- [Ruby](https://ikiform.com/de/guides/webhook-signature-verification/ruby.md): Verifiziere Ikiform-Webhook-Signaturen in Sinatra mit OpenSSL und einem Minitest-Test und in Rails mit dem Gem standardwebhooks und request.raw_post.
