Verify webhook signatures

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.

Preet SutharFounder, Ikiform

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.

The order of operations in a Go webhook handlerMaxBytesReader300 KBcapio.ReadAllone []byteVerifySignaturehmac.Equalper v1json.Unmarshalsame bytes204400err.Error()as thereasonerrorThe order of operations in a Go webhook handlerMaxBytesReader300 KB capio.ReadAllone []byteVerifySignaturehmac.Equal per v1json.Unmarshalsame bytes204400err.Error()as thereasonerror
  1. MaxBytesReader wraps the body so reading stops past 300 KB, above Ikiform's 256 KB payload cap.
  2. io.ReadAll reads the whole body into one byte slice.
  3. VerifySignature checks the headers, the timestamp and each v1 signature against those bytes.
  4. On success, json.Unmarshal parses the same slice into a struct.
  5. The handler answers 204. On any verification error it answers 400 with the reason.
A json.Decoder on r.Body would skip the second step and leave nothing to verify.

The verify function

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 canonicalizes the name, so webhook-id finds Webhook-Id however the request spelled it.
  • hmac.Equal is constant-time and returns false for different lengths, so a malformed signature is a plain non-match.
  • strings.Cut splits on the first comma only, which is what the spec asks for.
  • base64.StdEncoding is the padded standard alphabet. RawStdEncoding or URLEncoding would 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

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

In Gin, read the body with 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

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

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:

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
}

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:

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)

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:

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

  1. Run go test ./.... All five cases pass.
  2. Start the server behind a public https: URL and add it under Webhooks in the dashboard.
  3. Export IKIFORM_WEBHOOK_SECRET with the endpoint's whsec_ secret. The server refuses to start without a valid one.
  4. Choose Send test. The delivery log shows 204.
  5. Send a request with curl and a made-up signature. It gets 400 no_matching_signature.

Gotchas

ProblemCause and fix
The body is empty when you verifySomething read r.Body first, often a decoder or a logging middleware. Read it once and pass the slice along
illegal base64 data at startupThe secret was decoded with RawStdEncoding, or copied without its trailing =. Use StdEncoding and the full value
404 on the webhook pathPatterns 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 logThe MaxBytesReader limit is below 256 KB. Keep it above Ikiform's payload cap
The test vector fails with timestamp_too_oldThe test used time.Now(). Pass the fixed now from the test, as above

Questions

Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.