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

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

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.

Diagram: The order of operations in a Go webhook handler

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

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

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

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

Run `go test ./...`. The cases come from the [test vector](https://ikiform.com/guides/webhook-signature-verification.md#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`:

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

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

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

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

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

| 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

### How do I compare HMAC signatures safely in Go?

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.

### Why is r.Body empty after I decode JSON?

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.

### Is there a Standard Webhooks library for Go?

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.

### Should I use crypto/subtle instead of hmac.Equal?

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.

## More guides

- [Verify webhook signatures](https://ikiform.com/guides/webhook-signature-verification.md): Check that a form webhook really came from Ikiform: the Standard Webhooks HMAC-SHA256 scheme, a test vector, raw bodies, timestamps and secret rotation.
- [Node.js](https://ikiform.com/guides/webhook-signature-verification/nodejs.md): Verify Ikiform webhook signatures in Node.js: raw bodies in Express and Fastify, the SDK, the standardwebhooks package and a tested node:crypto version.
- [Python](https://ikiform.com/guides/webhook-signature-verification/python.md): Verify Ikiform webhook signatures in Flask, FastAPI and Django, with the standardwebhooks package or the standard library, plus a pytest test.
- [PHP](https://ikiform.com/guides/webhook-signature-verification/php.md): Verify Ikiform webhook signatures in plain PHP with hash_hmac and hash_equals, and in Laravel with the Standard Webhooks library and a CSRF exception.
- [Ruby](https://ikiform.com/guides/webhook-signature-verification/ruby.md): Verify Ikiform webhook signatures in Sinatra with OpenSSL and a Minitest test, and in Rails with the standardwebhooks gem and request.raw_post.
