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

Publicado el 2026-10-25 · https://ikiform.com/es/guides/webhook-signature-verification/go

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.

Diagrama: El orden de las operaciones en un handler de webhook en Go

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

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

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

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

Ejecuta `go test ./...`. Los casos salen del [vector de prueba](https://ikiform.com/es/guides/webhook-signature-verification.md#test-vector). `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`:

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

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

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

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

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

| 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

### ¿Cómo comparo firmas HMAC de forma segura en Go?

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.

### ¿Por qué r.Body está vacío después de decodificar el JSON?

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.

### ¿Hay una librería de Standard Webhooks para Go?

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.

### ¿Debo usar crypto/subtle en lugar de hmac.Equal?

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.

## Más guías

- [Verifica las firmas de webhook](https://ikiform.com/es/guides/webhook-signature-verification.md): Comprueba que un webhook de formulario viene de verdad de Ikiform: el esquema HMAC-SHA256 de Standard Webhooks, un vector de prueba, cuerpos sin procesar, marcas de tiempo y rotación de secretos.
- [Node.js](https://ikiform.com/es/guides/webhook-signature-verification/nodejs.md): Verifica las firmas de los webhooks de Ikiform en Node.js: cuerpos sin procesar en Express y Fastify, el SDK, el paquete standardwebhooks y una versión probada con node:crypto.
- [Python](https://ikiform.com/es/guides/webhook-signature-verification/python.md): Verifica las firmas de los webhooks de Ikiform en Flask, FastAPI y Django, con el paquete standardwebhooks o la librería estándar, más un test de pytest.
- [PHP](https://ikiform.com/es/guides/webhook-signature-verification/php.md): Verifica las firmas de los webhooks de Ikiform en PHP puro con hash_hmac y hash_equals, y en Laravel con la librería de Standard Webhooks y una excepción de CSRF.
- [Ruby](https://ikiform.com/es/guides/webhook-signature-verification/ruby.md): Verifica las firmas de los webhooks de Ikiform en Sinatra con OpenSSL y un test de Minitest, y en Rails con la gema standardwebhooks y request.raw_post.
