Verifica las firmas de webhook6 min de lectura
Verifica firmas de webhook en Go
Verifica las firmas de los webhooks de Ikiform en Go con net/http: lee el cuerpo una vez, compruébalo con crypto/hmac, pruébalo o usa la librería de Standard Webhooks.
Lee el cuerpo entero con io.ReadAll detrás de http.MaxBytesReader, calcula el HMAC-SHA256 de id.timestamp.body con el secreto whsec_ decodificado de base64 y compáralo con cada firma v1 usando hmac.Equal. Comprueba que la marca de tiempo esté dentro de 5 minutos y solo entonces haz json.Unmarshal de esos mismos bytes. La librería de Standard Webhooks para Go hace lo mismo en una sola llamada a Verify.
La librería estándar de Go tiene todo lo que necesita la comprobación, así que esta página no usa ningún framework ni dependencia hasta la librería opcional del final. El handler es net/http sin más, con los patrones de método y ruta añadidos en Go 1.22, y la misma función funciona sin cambios en chi, Gin o Echo.
Qué cambia en Go
Nada en net/http analiza el cuerpo por ti, lo que elimina la trampa habitual del cuerpo sin procesar. La trampa propia de Go es el stream en sí: r.Body solo se puede leer una vez. El idiomático json.NewDecoder(r.Body).Decode(&event) lo consume, y no queda nada que verificar. Así que lee primero los bytes en memoria, con un límite para que un envío grande no la agote, y usa ese único slice para ambas tareas.
- MaxBytesReader envuelve el cuerpo para que la lectura se detenga pasados 300 KB, por encima del tope de 256 KB del payload de Ikiform.
- io.ReadAll lee el cuerpo entero en un solo slice de bytes.
- VerifySignature comprueba las cabeceras, la marca de tiempo y cada firma v1 contra esos bytes.
- Si todo va bien, json.Unmarshal analiza ese mismo slice en un struct.
- El handler responde 204. Ante cualquier error de verificación responde 400 con el motivo.
La función de verificación
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.Getcanoniza el nombre, así quewebhook-idencuentraWebhook-Idcomo sea que lo escribiera la petición.hmac.Equales de tiempo constante y devuelvefalsesi las longitudes difieren, así que una firma mal formada simplemente no coincide.strings.Cutdivide solo por la primera coma, que es lo que pide la especificación.base64.StdEncodinges el alfabeto estándar con relleno.RawStdEncodingoURLEncodingrechazarían o decodificarían mal tanto el secreto como las firmas.- La clave se decodifica una vez al arrancar con
DecodeSecret, no en cada petición.
El handler
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 protege al servidor de clientes lentos; el http.ListenAndServe a secas no tiene ningún timeout. El struct conserva solo los campos que usas. json.Unmarshal ignora el resto, así que los campos nuevos del payload nunca lo rompen. Detrás de un proxy inverso como nginx, mantén también su límite de cuerpo, client_max_body_size, por encima de 256 KB, o el proxy rechazará los envíos grandes antes de que Go los vea. El cuerpo del 400 lleva el texto del error, como no_matching_signature, y el registro de entregas muestra los primeros 4 KB de cada respuesta, así que un fallo se explica solo ahí.
c.GetRawData(); en Echo, io.ReadAll(c.Request().Body); en chi, el handler de arriba funciona tal cual. No llames a c.ShouldBindJSON ni a c.Bind antes de verificar, por la misma razón de la lectura única.Un test de tabla
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)
}
})
}
}Ejecuta go test ./.... Los casos salen del vector de prueba. json.Indent da el cuerpo re-serializado que podría producir un código Go real, y el now fijo mantiene la marca de tiempo del vector dentro de la ventana.
Repeticiones y trabajo lento
Pasan dos cosas después de verificar una entrega. Primero, sáltala si ya la has visto: Ikiform reintenta con el mismo webhook-id cuando no recibe un 2xx en 15 segundos, aunque tu handler haya terminado. Con PostgreSQL y una columna única, RowsAffected te dice si es la primera vez:
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
}Segundo, responde antes del trabajo lento. Una goroutine es la herramienta obvia, con una trampa propia de Go: r.Context() se cancela en cuanto el handler devuelve, así que cualquier llamada a la base de datos o petición HTTP hecha con él en segundo plano falla con context canceled. Dale a la goroutine su propio contexto:
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)Una goroutine muere con el proceso, así que un despliegue a mitad del trabajo la pierde. Para cualquier cosa que no te puedas permitir perder, escribe el evento en una cola o en una tabla dentro del handler, responde 204 y procésalo desde ahí.
Con la librería de Standard Webhooks
La librería de Go de la especificación está en el módulo libraries de su repositorio. Añádela con 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 quita whsec_ y decodifica el secreto. Verify recibe directamente el cuerpo y r.Header y devuelve un error que envuelve ErrNoMatchingSignature, ErrMessageTooOld y los demás, así que puedes distinguirlos con errors.Is. También existe VerifyIgnoringTimestamp; no lo uses en producción, porque vuelve a abrir la puerta a los ataques de repetición.
Comprueba que funciona
- Ejecuta
go test ./.... Los cinco casos pasan. - Arranca el servidor detrás de una URL
https:pública y añádela en Webhooks en el panel. - Exporta
IKIFORM_WEBHOOK_SECRETcon el secretowhsec_del endpoint. El servidor no arranca sin uno válido. - Elige Send test. El registro de entregas muestra
204. - Envía una petición con curl y una firma inventada. Recibe
400 no_matching_signature.
Errores comunes
| Problema | Causa y solución |
|---|---|
| El cuerpo está vacío cuando verificas | Algo leyó r.Body antes, a menudo un decoder o un middleware de logs. Léelo una vez y pasa el slice |
illegal base64 data al arrancar | El secreto se decodificó con RawStdEncoding, o se copió sin su = final. Usa StdEncoding y el valor completo |
404 en la ruta del webhook | Patrones como POST /webhooks/ikiform necesitan Go 1.22 o posterior y una línea go 1.22 o posterior en go.mod; con configuraciones anteriores, el patrón se trata como una ruta literal |
413 en el registro de entregas | El límite de MaxBytesReader está por debajo de 256 KB. Mantenlo por encima del tope del payload de Ikiform |
El vector de prueba falla con timestamp_too_old | El test usó time.Now(). Pasa el now fijo desde el test, como arriba |
Preguntas
Usa hmac.Equal de crypto/hmac. Compara en tiempo constante y devuelve false cuando las longitudes difieren. No uses bytes.Equal ni ==, que pueden terminar en cuanto un byte es distinto.
El cuerpo de la petición es un stream y solo se puede leer una vez. Léelo en un slice de bytes con io.ReadAll, verifica el slice y luego haz json.Unmarshal de ese mismo slice.
Sí, en el repositorio standard-webhooks, en libraries/go. NewWebhook(secret) recibe el secreto whsec_, y Verify(body, r.Header) devuelve un error cuando falla una comprobación.
No hace falta. hmac.Equal llama a subtle.ConstantTimeCompare por ti y es la forma documentada de comparar MACs. Ambos son de tiempo constante; == y bytes.Equal no lo son.
¿Te atascaste en un paso o quieres una guía para tu caso? Escribe a hello@ikiform.com.