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.
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.
- O MaxBytesReader envolve o corpo para que a leitura pare depois de 300 KB, acima do teto de 256 KB de payload do Ikiform.
- O io.ReadAll lê o corpo inteiro numa única slice de bytes.
- O VerifySignature confere os headers, o timestamp e cada assinatura v1 contra esses bytes.
- Se der certo, o json.Unmarshal faz o parse da mesma slice numa struct.
- O handler responde 204. Em qualquer erro de verificação, responde 400 com o motivo.
A função de verificação
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.Getcanonicaliza o nome, entãowebhook-idencontraWebhook-Idseja qual for a grafia usada na requisição.hmac.Equalé de tempo constante e devolvefalsepara tamanhos diferentes, então uma assinatura malformada simplesmente não bate.strings.Cutdivide só na primeira vírgula, que é o que a especificação pede.base64.StdEncodingé o alfabeto padrão com padding.RawStdEncodingouURLEncodingrecusariam 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
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.
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
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:
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:
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:
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
- Rode
go test ./.... Os cinco casos passam. - Suba o servidor atrás de uma URL
https:pública e adicione-a em Webhooks no painel. - Exporte
IKIFORM_WEBHOOK_SECRETcom o segredowhsec_do endpoint. O servidor se recusa a iniciar sem um válido. - Escolha Send test. O log de entregas mostra
204. - Envie uma requisição com curl e uma assinatura inventada. Ela recebe
400 no_matching_signature.
Pegadinhas
| Problema | Causa e correção |
|---|---|
| O corpo está vazio na hora de verificar | Algo 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ção | O segredo foi decodificado com RawStdEncoding, ou copiado sem o = final. Use StdEncoding e o valor completo |
404 no caminho do webhook | Padrõ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 entregas | O 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_old | O teste usou time.Now(). Passe o now fixo do teste, como acima |
Perguntas
Use hmac.Equal de crypto/hmac. Ele compara em tempo constante e devolve false quando os tamanhos são diferentes. Não use bytes.Equal nem ==, que podem retornar assim que um byte for diferente.
O corpo da requisição é um stream e só pode ser lido uma vez. Leia-o numa slice de bytes com io.ReadAll, verifique a slice e depois faça json.Unmarshal da mesma slice.
Sim, no repositório standard-webhooks, em libraries/go. NewWebhook(secret) recebe o segredo whsec_, e Verify(body, r.Header) devolve um erro quando uma verificação falha.
Não precisa. O hmac.Equal chama subtle.ConstantTimeCompare por você e é a forma documentada de comparar MACs. Os dois são de tempo constante; == e bytes.Equal não são.
Travou em algum passo ou quer um guia para o seu caso? Envie um e-mail para hello@ikiform.com.