Vérifier les signatures de webhook

Vérifier les signatures de webhook6 min de lecture

Vérifier les signatures de webhook en Go

Vérifiez les signatures des webhooks Ikiform en Go avec net/http : lisez le corps une seule fois, vérifiez-le avec crypto/hmac, testez-le, ou utilisez la bibliothèque Standard Webhooks.

Preet SutharFondateur, Ikiform

Lisez tout le corps avec io.ReadAll derrière http.MaxBytesReader, puis calculez le HMAC-SHA256 de id.timestamp.body avec le secret whsec_ décodé en base64 et comparez-le à chaque signature v1 avec hmac.Equal. Vérifiez que le timestamp date de moins de 5 minutes, et seulement ensuite appliquez json.Unmarshal aux mêmes octets. La bibliothèque Go de Standard Webhooks fait la même chose en un seul appel Verify.

La bibliothèque standard de Go contient tout ce dont la vérification a besoin, donc cette page n'utilise ni framework ni dépendance jusqu'à la bibliothèque facultative de la fin. Le gestionnaire est du net/http simple avec les patterns méthode et chemin ajoutés dans Go 1.22, et la même fonction s'intègre telle quelle dans chi, Gin ou Echo.

Ce qui diffère en Go

Rien dans net/http n'analyse le corps à votre place, ce qui élimine le piège habituel du corps brut. Le piège propre à Go, c'est le flux lui-même : r.Body ne peut être lu qu'une fois. L'idiomatique json.NewDecoder(r.Body).Decode(&event) le consomme, et il ne reste rien à vérifier. Lisez donc d'abord les octets en mémoire, avec une limite pour qu'un gros envoi ne puisse pas l'épuiser, et utilisez cette unique slice pour les deux tâches.

L'ordre des opérations dans un gestionnaire de webhook en GoMaxBytesReaderplafond de300 Koio.ReadAllun seul[]byteVerifySignaturehmac.Equalpourchaque v1json.Unmarshalmêmesoctets204400err.Error()commeraisonerreurL'ordre des opérations dans un gestionnaire de webhook en GoMaxBytesReaderplafond de 300 Koio.ReadAllun seul []byteVerifySignaturehmac.Equal pour chaquev1json.Unmarshalmêmes octets204400err.Error()comme raisonerreur
  1. MaxBytesReader enveloppe le corps pour que la lecture s'arrête au-delà de 300 Ko, au-dessus du plafond de payload de 256 Ko d'Ikiform.
  2. io.ReadAll lit tout le corps dans une seule slice d'octets.
  3. VerifySignature vérifie les en-têtes, le timestamp et chaque signature v1 par rapport à ces octets.
  4. En cas de succès, json.Unmarshal analyse la même slice dans une struct.
  5. Le gestionnaire répond 204. Sur toute erreur de vérification, il répond 400 avec la raison.
Un json.Decoder sur r.Body sauterait la deuxième étape et ne laisserait rien à vérifier.

La fonction de vérification

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 canonise le nom, donc webhook-id trouve Webhook-Id quelle que soit l'orthographe de la requête.
  • hmac.Equal est en temps constant et renvoie false pour des longueurs différentes, donc une signature malformée est une simple non-correspondance.
  • strings.Cut ne découpe que sur la première virgule, ce que demande la spécification.
  • base64.StdEncoding est l'alphabet standard avec padding. RawStdEncoding ou URLEncoding refuseraient ou décoderaient mal à la fois le secret et les signatures.
  • La clé est décodée une seule fois au démarrage par DecodeSecret, pas à chaque requête.

Le gestionnaire

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 protège le serveur des clients lents ; le http.ListenAndServe nu n'a aucun délai d'expiration. La struct ne garde que les champs que vous utilisez. json.Unmarshal ignore le reste, donc les nouveaux champs du payload ne la cassent jamais. Derrière un reverse proxy comme nginx, gardez aussi sa limite de corps, client_max_body_size, au-dessus de 256 Ko, sinon le proxy refuse les grosses soumissions avant que Go ne les voie. Le corps du 400 contient le texte de l'erreur, comme no_matching_signature, et le journal de livraison affiche les 4 premiers Ko de chaque réponse, donc un échec s'y explique de lui-même.

Dans Gin, lisez le corps avec c.GetRawData() ; dans Echo, io.ReadAll(c.Request().Body) ; dans chi, le gestionnaire ci-dessus fonctionne tel quel. N'appelez pas c.ShouldBindJSON ni c.Bind avant la vérification, pour la même raison de lecture unique.

Un test en tableau

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)
			}
		})
	}
}

Lancez go test ./.... Les cas viennent du vecteur de test. json.Indent produit le corps resérialisé qu'une vraie base de code Go pourrait générer, et le now fixe garde le timestamp du vecteur dans la fenêtre.

Répétitions et travail lent

Deux choses se passent après la vérification d'une livraison. D'abord, ignorez-la si vous l'avez déjà vue : Ikiform réessaie avec le même webhook-id quand il ne reçoit pas de 2xx en 15 secondes, même si votre gestionnaire a terminé. Avec PostgreSQL et une colonne unique, RowsAffected vous dit si c'est la première fois :

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
}

Ensuite, répondez avant le travail lent. Une goroutine est l'outil évident, avec un piège propre à Go : r.Context() est annulé dès que le gestionnaire retourne, donc tout appel à la base de données ou toute requête HTTP faite en arrière-plan avec ce contexte échoue avec context canceled. Donnez à la goroutine son propre contexte :

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)

Une goroutine meurt avec le processus, donc un déploiement en plein travail la perd. Pour tout ce que vous ne pouvez pas vous permettre de perdre, écrivez l'événement dans une file d'attente ou une table dans le gestionnaire, répondez 204 et traitez-le à partir de là.

Avec la bibliothèque Standard Webhooks

La bibliothèque Go de la spécification se trouve dans le module libraries de son dépôt. Ajoutez-la avec go get github.com/standard-webhooks/standard-webhooks/libraries :

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 retire whsec_ et décode le secret. Verify prend directement le corps et r.Header et renvoie une erreur qui enveloppe ErrNoMatchingSignature, ErrMessageTooOld et les autres, donc vous pouvez les distinguer avec errors.Is. Il existe aussi VerifyIgnoringTimestamp ; ne l'utilisez pas en production, car il rouvre la porte aux rejeux.

Vérifiez que tout fonctionne

  1. Lancez go test ./.... Les cinq cas passent.
  2. Démarrez le serveur derrière une URL https: publique et ajoutez-la sous Webhooks dans le tableau de bord.
  3. Exportez IKIFORM_WEBHOOK_SECRET avec le secret whsec_ de l'endpoint. Le serveur refuse de démarrer sans secret valide.
  4. Choisissez Send test. Le journal de livraison affiche 204.
  5. Envoyez une requête avec curl et une signature inventée. Elle reçoit 400 no_matching_signature.

Pièges

ProblèmeCause et solution
Le corps est vide au moment de la vérificationQuelque chose a lu r.Body en premier, souvent un décodeur ou un middleware de journalisation. Lisez-le une fois et transmettez la slice
illegal base64 data au démarrageLe secret a été décodé avec RawStdEncoding, ou copié sans son = final. Utilisez StdEncoding et la valeur complète
404 sur le chemin du webhookLes patterns comme POST /webhooks/ikiform nécessitent Go 1.22 ou plus récent et une ligne go 1.22 ou plus récente dans go.mod ; les réglages plus anciens traitent le pattern comme un chemin littéral
413 dans le journal de livraisonLa limite de MaxBytesReader est inférieure à 256 Ko. Gardez-la au-dessus du plafond de payload d'Ikiform
Le vecteur de test échoue avec timestamp_too_oldLe test a utilisé time.Now(). Passez le now fixe du test, comme ci-dessus

Questions

Une étape vous bloque, ou vous voulez un guide pour votre cas ? Écrivez à hello@ikiform.com.