Verificar assinaturas de webhook

Verificar assinaturas de webhook6 min de leitura

Verificar assinaturas de webhook em Go

Verifique assinaturas de webhook do Ikiform em Go com net/http: leia o corpo uma vez, confira com crypto/hmac, teste, ou use a biblioteca do Standard Webhooks.

Preet SutharFundador, Ikiform

Leia o corpo inteiro com io.ReadAll atrás de http.MaxBytesReader, depois calcule o HMAC-SHA256 de id.timestamp.body com o segredo whsec_ decodificado de base64 e compare com cada assinatura v1 usando hmac.Equal. Confira se o timestamp está dentro de 5 minutos e só então faça json.Unmarshal dos mesmos bytes. A biblioteca Go do Standard Webhooks faz o mesmo numa única chamada Verify.

A biblioteca padrão do Go tem tudo de que a verificação precisa, então esta página não usa framework nem dependência até a biblioteca opcional no final. O handler é net/http puro com os padrões de método e caminho adicionados no Go 1.22, e a mesma função encaixa no chi, Gin ou Echo sem mudanças.

O que muda no Go

Nada no net/http faz o parse do corpo por você, o que elimina a armadilha de sempre do corpo bruto. A armadilha específica do Go é o próprio stream: r.Body só pode ser lido uma vez. O idiomático json.NewDecoder(r.Body).Decode(&event) o consome, e não sobra nada para verificar. Então leia os bytes para a memória primeiro, com um limite para que um envio grande não a esgote, e use essa única slice para as duas tarefas.

A ordem das operações num handler de webhook em GoMaxBytesReaderteto de300 KBio.ReadAllum []byteVerifySignaturehmac.Equalpor v1json.Unmarshalmesmosbytes204400err.Error()comomotivoerroA ordem das operações num handler de webhook em GoMaxBytesReaderteto de 300 KBio.ReadAllum []byteVerifySignaturehmac.Equal por v1json.Unmarshalmesmos bytes204400err.Error()como motivoerro
  1. O MaxBytesReader envolve o corpo para que a leitura pare depois de 300 KB, acima do teto de 256 KB de payload do Ikiform.
  2. O io.ReadAll lê o corpo inteiro numa única slice de bytes.
  3. O VerifySignature confere os headers, o timestamp e cada assinatura v1 contra esses bytes.
  4. Se der certo, o json.Unmarshal faz o parse da mesma slice numa struct.
  5. O handler responde 204. Em qualquer erro de verificação, responde 400 com o motivo.
Um json.Decoder em r.Body pularia o segundo passo e não deixaria nada para verificar.

A função de verificação

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 canonicaliza o nome, então webhook-id encontra Webhook-Id seja qual for a grafia usada na requisição.
  • hmac.Equal é de tempo constante e devolve false para tamanhos diferentes, então uma assinatura malformada simplesmente não bate.
  • strings.Cut divide só na primeira vírgula, que é o que a especificação pede.
  • base64.StdEncoding é o alfabeto padrão com padding. RawStdEncoding ou URLEncoding recusariam ou decodificariam errado tanto o segredo quanto as assinaturas.
  • A chave é decodificada uma vez na inicialização por DecodeSecret, não a cada requisição.

O 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 o servidor de clientes lentos; o http.ListenAndServe puro não tem timeout nenhum. A struct guarda só os campos que você usa. json.Unmarshal ignora o resto, então campos novos no payload nunca a quebram. Atrás de um proxy reverso como o nginx, mantenha também o limite de corpo dele, client_max_body_size, acima de 256 KB, ou o proxy recusa envios grandes antes de o Go vê-los. O corpo do 400 traz o texto do erro, como no_matching_signature, e o log de entregas mostra os primeiros 4 KB de cada resposta, então uma falha se explica ali mesmo.

No Gin, leia o corpo com c.GetRawData(); no Echo, io.ReadAll(c.Request().Body); no chi, o handler acima funciona como está. Não chame c.ShouldBindJSON nem c.Bind antes de verificar, pelo mesmo motivo da leitura única.

Um teste de tabela

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

Rode go test ./.... Os casos vêm do vetor de teste. json.Indent gera o corpo reserializado que uma base de código Go real poderia produzir, e o now fixo mantém o timestamp do vetor dentro da janela.

Repetições e trabalho lento

Duas coisas acontecem depois que uma entrega é verificada. Primeiro, ignore-a se já a viu: o Ikiform tenta de novo com o mesmo webhook-id quando não recebe um 2xx em 15 segundos, mesmo que o seu handler tenha terminado. Com PostgreSQL e uma coluna única, RowsAffected diz se é a primeira 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, responda antes do trabalho lento. Uma goroutine é a ferramenta óbvia, com uma armadilha específica do Go: r.Context() é cancelado assim que o handler retorna, então qualquer chamada ao banco ou requisição HTTP feita com ele em segundo plano falha com context canceled. Dê à goroutine um contexto próprio:

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)

Uma goroutine morre junto com o processo, então um deploy no meio do trabalho o perde. Para qualquer coisa que você não pode perder, grave o evento numa fila ou numa tabela dentro do handler, responda 204 e processe a partir dali.

Com a biblioteca do Standard Webhooks

A biblioteca Go da especificação fica no módulo libraries do repositório dela. Adicione com 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 tira o whsec_ e decodifica o segredo. Verify recebe o corpo e r.Header diretamente e devolve um erro que envolve ErrNoMatchingSignature, ErrMessageTooOld e os outros, então você consegue diferenciá-los com errors.Is. Existe também VerifyIgnoringTimestamp; não use em produção, porque ele reabre a porta para replays.

Confira se funciona

  1. Rode go test ./.... Os cinco casos passam.
  2. Suba o servidor atrás de uma URL https: pública e adicione-a em Webhooks no painel.
  3. Exporte IKIFORM_WEBHOOK_SECRET com o segredo whsec_ do endpoint. O servidor se recusa a iniciar sem um válido.
  4. Escolha Send test. O log de entregas mostra 204.
  5. Envie uma requisição com curl e uma assinatura inventada. Ela recebe 400 no_matching_signature.

Pegadinhas

ProblemaCausa e correção
O corpo está vazio na hora de verificarAlgo leu r.Body antes, muitas vezes um decoder ou um middleware de log. Leia uma vez e repasse a slice
illegal base64 data na inicializaçãoO segredo foi decodificado com RawStdEncoding, ou copiado sem o = final. Use StdEncoding e o valor completo
404 no caminho do webhookPadrões como POST /webhooks/ikiform precisam do Go 1.22 ou posterior e de uma linha go 1.22 ou posterior no go.mod; configurações mais antigas tratam o padrão como um caminho literal
413 no log de entregasO limite do MaxBytesReader está abaixo de 256 KB. Mantenha-o acima do teto de payload do Ikiform
O vetor de teste falha com timestamp_too_oldO teste usou time.Now(). Passe o now fixo do teste, como acima

Perguntas

Travou em algum passo ou quer um guia para o seu caso? Envie um e-mail para hello@ikiform.com.