Verify webhook signatures6 min read
Verify webhook signatures in Go
Verify Ikiform webhook signatures in Go with net/http: read the body once, check it with crypto/hmac, test it, or use the Standard Webhooks library.
Read the whole body with io.ReadAll behind http.MaxBytesReader, then compute HMAC-SHA256 of id.timestamp.body with the base64-decoded whsec_ secret and compare it with each v1 signature using hmac.Equal. Check the timestamp is within 5 minutes, and only then json.Unmarshal the same bytes. The Standard Webhooks Go library does the same in one Verify call.
Go's standard library has everything the check needs, so this page uses no framework and no dependency until the optional library at the end. The handler is plain net/http with the method-and-path patterns added in Go 1.22, and the same function drops into chi, Gin or Echo unchanged.
What's different in Go
Nothing in net/http parses the body for you, which removes the usual raw-body trap. The Go-specific trap is the stream itself: r.Body can be read once. The idiomatic json.NewDecoder(r.Body).Decode(&event) consumes it, and there's nothing left to verify. So read the bytes into memory first, bounded so a large post can't exhaust it, and use that one slice for both jobs.
- MaxBytesReader wraps the body so reading stops past 300 KB, above Ikiform's 256 KB payload cap.
- io.ReadAll reads the whole body into one byte slice.
- VerifySignature checks the headers, the timestamp and each v1 signature against those bytes.
- On success, json.Unmarshal parses the same slice into a struct.
- The handler answers 204. On any verification error it answers 400 with the reason.
The verify function
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.Getcanonicalizes the name, sowebhook-idfindsWebhook-Idhowever the request spelled it.hmac.Equalis constant-time and returnsfalsefor different lengths, so a malformed signature is a plain non-match.strings.Cutsplits on the first comma only, which is what the spec asks for.base64.StdEncodingis the padded standard alphabet.RawStdEncodingorURLEncodingwould refuse or mis-decode both the secret and the signatures.- The key is decoded once at startup by
DecodeSecret, not on every request.
The 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 protects the server from slow clients; the bare http.ListenAndServe has no timeouts at all. The struct keeps only the fields you use. json.Unmarshal ignores the rest, so new payload fields never break it. Behind a reverse proxy such as nginx, keep its body limit, client_max_body_size, above 256 KB as well, or the proxy refuses large submissions before Go sees them. The 400 body carries the error's text, such as no_matching_signature, and the delivery log shows the first 4 KB of every response, so a failure explains itself there.
c.GetRawData(); in Echo, io.ReadAll(c.Request().Body); in chi, the handler above works as-is. Don't call c.ShouldBindJSON or c.Bind before verifying, for the same one-read reason.A table test
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)
}
})
}
}Run go test ./.... The cases come from the test vector. json.Indent gives the re-serialized body a real Go codebase might produce, and the fixed now keeps the vector's timestamp inside the window.
Repeats and slow work
Two things happen after a delivery is verified. First, skip it if you've seen it: Ikiform retries with the same webhook-id when it doesn't get a 2xx within 15 seconds, even if your handler finished. With PostgreSQL and a unique column, RowsAffected tells you whether this is the first time:
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
}Second, answer before slow work. A goroutine is the obvious tool, with one Go-specific trap: r.Context() is canceled as soon as the handler returns, so any database call or HTTP request made with it in the background fails with context canceled. Give the goroutine its own context:
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)A goroutine dies with the process, so a deploy in the middle of the work loses it. For anything you can't afford to lose, write the event to a queue or a table inside the handler, answer 204, and process it from there.
With the Standard Webhooks library
The spec's Go library lives in the libraries module of its repository. Add it with 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 strips whsec_ and decodes the secret. Verify takes the body and r.Header directly and returns an error that wraps ErrNoMatchingSignature, ErrMessageTooOld and the others, so you can tell them apart with errors.Is. There's also VerifyIgnoringTimestamp; don't use it in production, since it re-opens replays.
Check it works
- Run
go test ./.... All five cases pass. - Start the server behind a public
https:URL and add it under Webhooks in the dashboard. - Export
IKIFORM_WEBHOOK_SECRETwith the endpoint'swhsec_secret. The server refuses to start without a valid one. - Choose Send test. The delivery log shows
204. - Send a request with curl and a made-up signature. It gets
400 no_matching_signature.
Gotchas
| Problem | Cause and fix |
|---|---|
| The body is empty when you verify | Something read r.Body first, often a decoder or a logging middleware. Read it once and pass the slice along |
illegal base64 data at startup | The secret was decoded with RawStdEncoding, or copied without its trailing =. Use StdEncoding and the full value |
404 on the webhook path | Patterns like POST /webhooks/ikiform need Go 1.22 or later and a go 1.22 or later line in go.mod; older settings treat the pattern as a literal path |
413 in the delivery log | The MaxBytesReader limit is below 256 KB. Keep it above Ikiform's payload cap |
The test vector fails with timestamp_too_old | The test used time.Now(). Pass the fixed now from the test, as above |
Questions
Use hmac.Equal from crypto/hmac. It compares in constant time and returns false when the lengths differ. Don't use bytes.Equal or ==, which can return as soon as one byte differs.
The request body is a stream and can be read once. Read it into a byte slice with io.ReadAll, verify the slice, then json.Unmarshal the same slice.
Yes, in the standard-webhooks repository under libraries/go. NewWebhook(secret) takes the whsec_ secret, and Verify(body, r.Header) returns an error when a check fails.
There's no need. hmac.Equal calls subtle.ConstantTimeCompare for you and is the documented way to compare MACs. Either is constant-time; == and bytes.Equal are not.
Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.