Verifica las firmas de webhook

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.

Preet SutharFundador, Ikiform

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.

El orden de las operaciones en un handler de webhook en GoMaxBytesReadertope de300 KBio.ReadAllun solo[]byteVerifySignaturehmac.Equalpor cadav1json.Unmarshallos mismosbytes204400err.Error()comomotivoerrorEl orden de las operaciones en un handler de webhook en GoMaxBytesReadertope de 300 KBio.ReadAllun solo []byteVerifySignaturehmac.Equal por cada v1json.Unmarshallos mismos bytes204400err.Error()como motivoerror
  1. MaxBytesReader envuelve el cuerpo para que la lectura se detenga pasados 300 KB, por encima del tope de 256 KB del payload de Ikiform.
  2. io.ReadAll lee el cuerpo entero en un solo slice de bytes.
  3. VerifySignature comprueba las cabeceras, la marca de tiempo y cada firma v1 contra esos bytes.
  4. Si todo va bien, json.Unmarshal analiza ese mismo slice en un struct.
  5. El handler responde 204. Ante cualquier error de verificación responde 400 con el motivo.
Un json.Decoder sobre r.Body se saltaría el segundo paso y no dejaría nada que verificar.

La función de verificación

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 canoniza el nombre, así que webhook-id encuentra Webhook-Id como sea que lo escribiera la petición.
  • hmac.Equal es de tiempo constante y devuelve false si las longitudes difieren, así que una firma mal formada simplemente no coincide.
  • strings.Cut divide solo por la primera coma, que es lo que pide la especificación.
  • base64.StdEncoding es el alfabeto estándar con relleno. RawStdEncoding o URLEncoding rechazarí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

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

En Gin, lee el cuerpo con 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

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

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:

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
}

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:

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)

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:

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

  1. Ejecuta go test ./.... Los cinco casos pasan.
  2. Arranca el servidor detrás de una URL https: pública y añádela en Webhooks en el panel.
  3. Exporta IKIFORM_WEBHOOK_SECRET con el secreto whsec_ del endpoint. El servidor no arranca sin uno válido.
  4. Elige Send test. El registro de entregas muestra 204.
  5. Envía una petición con curl y una firma inventada. Recibe 400 no_matching_signature.

Errores comunes

ProblemaCausa y solución
El cuerpo está vacío cuando verificasAlgo 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 arrancarEl secreto se decodificó con RawStdEncoding, o se copió sin su = final. Usa StdEncoding y el valor completo
404 en la ruta del webhookPatrones 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 entregasEl 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_oldEl test usó time.Now(). Pasa el now fijo desde el test, como arriba

Preguntas

¿Te atascaste en un paso o quieres una guía para tu caso? Escribe a hello@ikiform.com.