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.
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.
- 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.
- io.ReadAll lit tout le corps dans une seule slice d'octets.
- VerifySignature vérifie les en-têtes, le timestamp et chaque signature v1 par rapport à ces octets.
- En cas de succès, json.Unmarshal analyse la même slice dans une struct.
- Le gestionnaire répond 204. Sur toute erreur de vérification, il répond 400 avec la raison.
La fonction de vérification
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.Getcanonise le nom, doncwebhook-idtrouveWebhook-Idquelle que soit l'orthographe de la requête.hmac.Equalest en temps constant et renvoiefalsepour des longueurs différentes, donc une signature malformée est une simple non-correspondance.strings.Cutne découpe que sur la première virgule, ce que demande la spécification.base64.StdEncodingest l'alphabet standard avec padding.RawStdEncodingouURLEncodingrefuseraient 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
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.
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
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 :
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 :
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 :
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
- Lancez
go test ./.... Les cinq cas passent. - Démarrez le serveur derrière une URL
https:publique et ajoutez-la sous Webhooks dans le tableau de bord. - Exportez
IKIFORM_WEBHOOK_SECRETavec le secretwhsec_de l'endpoint. Le serveur refuse de démarrer sans secret valide. - Choisissez Send test. Le journal de livraison affiche
204. - Envoyez une requête avec curl et une signature inventée. Elle reçoit
400 no_matching_signature.
Pièges
| Problème | Cause et solution |
|---|---|
| Le corps est vide au moment de la vérification | Quelque 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émarrage | Le secret a été décodé avec RawStdEncoding, ou copié sans son = final. Utilisez StdEncoding et la valeur complète |
404 sur le chemin du webhook | Les 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 livraison | La 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_old | Le test a utilisé time.Now(). Passez le now fixe du test, comme ci-dessus |
Questions
Utilisez hmac.Equal de crypto/hmac. Il compare en temps constant et renvoie false quand les longueurs diffèrent. N'utilisez pas bytes.Equal ni ==, qui peuvent s'arrêter dès qu'un octet diffère.
Le corps de la requête est un flux et ne peut être lu qu'une fois. Lisez-le dans une slice d'octets avec io.ReadAll, vérifiez la slice, puis appliquez json.Unmarshal à la même slice.
Oui, dans le dépôt standard-webhooks sous libraries/go. NewWebhook(secret) prend le secret whsec_, et Verify(body, r.Header) renvoie une erreur quand une vérification échoue.
Ce n'est pas nécessaire. hmac.Equal appelle subtle.ConstantTimeCompare pour vous et c'est la façon documentée de comparer des MAC. Les deux sont en temps constant ; == et bytes.Equal ne le sont pas.
Une étape vous bloque, ou vous voulez un guide pour votre cas ? Écrivez à hello@ikiform.com.