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

Publié le 2026-10-25 · https://ikiform.com/fr/guides/webhook-signature-verification/go

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.

Schéma : L'ordre des opérations dans un gestionnaire de webhook en Go

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

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

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

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

Lancez `go test ./...`. Les cas viennent du [vecteur de test](https://ikiform.com/fr/guides/webhook-signature-verification.md#test-vector). `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`:

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

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

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

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

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

### Comment comparer des signatures HMAC en toute sécurité en Go ?

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.

### Pourquoi r.Body est-il vide après le décodage du JSON ?

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.

### Existe-t-il une bibliothèque Standard Webhooks pour Go ?

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.

### Faut-il utiliser crypto/subtle plutôt que hmac.Equal ?

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.

## Autres guides

- [Vérifier les signatures de webhook](https://ikiform.com/fr/guides/webhook-signature-verification.md): Vérifiez qu'un webhook de formulaire vient bien d'Ikiform : le schéma HMAC-SHA256 de Standard Webhooks, un vecteur de test, les corps bruts, les timestamps et la rotation du secret.
- [Node.js](https://ikiform.com/fr/guides/webhook-signature-verification/nodejs.md): Vérifiez les signatures des webhooks Ikiform en Node.js : corps bruts dans Express et Fastify, le SDK, le package standardwebhooks et une version node:crypto testée.
- [Python](https://ikiform.com/fr/guides/webhook-signature-verification/python.md): Vérifiez les signatures des webhooks Ikiform dans Flask, FastAPI et Django, avec le package standardwebhooks ou la bibliothèque standard, plus un test pytest.
- [PHP](https://ikiform.com/fr/guides/webhook-signature-verification/php.md): Vérifiez les signatures des webhooks Ikiform en PHP simple avec hash_hmac et hash_equals, et dans Laravel avec la bibliothèque Standard Webhooks et une exception CSRF.
- [Ruby](https://ikiform.com/fr/guides/webhook-signature-verification/ruby.md): Vérifiez les signatures des webhooks Ikiform dans Sinatra avec OpenSSL et un test Minitest, et dans Rails avec la gem standardwebhooks et request.raw_post.
