Webhooks

Webhook signature checks that fail or quietly pass in Go

· 9 min read

On this page (13)
  1. Who needs this
  2. The three schemes side by side
  3. Pitfall 1: verifying parsed JSON instead of the raw body
  4. Pitfall 2: a proxy or gateway that changes the body
  5. Pitfall 3: comparing with ==
  6. Pitfall 4: ignoring the timestamp, or checking it wrong
  7. Pitfall 5: getting the secret's format wrong
  8. Pitfall 6: rotating secrets with downtime
  9. Pitfall 7: doing work before verifying
  10. Pitfall 8: trusting a valid signature too much
  11. How to test the checks
  12. Mistakes we see
  13. FAQ

A webhook signature is a promise that the request came from the sender and was not changed on the way. Most implementations get the HMAC itself right and then fail on something around it: the bytes that were signed, the clock, the secret's format, the comparison. This post lists the pitfalls in the order they usually bite, with Go code, and compares how GitHub, Stripe and Standard Webhooks (used by Polar) handle each.

Two ways a signature check goes wrong

Comparing a check that is too strict, which rejects real deliveries with 401, with a check that is too loose, which accepts forged or replayed requests.Too strictBody re-encoded before verifyingSecret decoded the wrong wayReal orders bounce with 401Too looseEmpty key, anyone can signTimestamp never comparedString comparison leaks timing

Who needs this

A developer wiring up Polar sales notifications. Every test request returns 401. The code looks like the docs. The cause is one of three things: the secret is not decoded the way the scheme expects, a middleware consumed the body, or the secret was created before a format change. Knowing the list shortens a day of guessing to ten minutes.

A team adding GitHub push webhooks to trigger CI. It works in testing with curl and fails when GitHub sends it, because the webhook was configured with the form-encoded content type and the code treats the body as JSON, or a framework parsed it first.

Anyone who ran a Stripe endpoint for a year and now must rotate the secret. The new secret goes live, and for a few minutes events signed with the old one bounce. Stripe's rolling feature exists for this, but the receiver has to accept two secrets at once.

The reverse failure is worse and quieter: a check that accepts things it should not. A verifier with an empty key, a timestamp that is never compared, or a string comparison that leaks timing is a door that looks locked.

The three schemes side by side

GitHubStripeStandard Webhooks (Polar)
HeaderX-Hub-Signature-256Stripe-Signaturewebhook-signature plus webhook-id, webhook-timestamp
Formatsha256=<hex>t=<unix>,v1=<hex>v1,<base64>, space-separated list
Signed contentraw body<t>.<raw body><id>.<timestamp>.<raw body>
Secret as keythe string's bytesthe whole whsec_... string's bytesbase64-decoded part after whsec_
Timestamp in signaturenoyesyes
Event id for dedupeX-GitHub-Delivery headerid in the bodywebhook-id header
Several secrets at oncenot in one headerrepeated v1= entriesseveral entries in one header

Read the Stripe row twice. Stripe secrets start with whsec_ too, but the official libraries use the entire string as the HMAC key without base64-decoding it. Standard Webhooks decodes. Two providers, the same prefix, opposite handling, which is a classic way to write a shared helper that works for one and fails for the other.

Pitfall 1: verifying parsed JSON instead of the raw body

The signature is over bytes. If you decode into a struct and marshal it again, key order, spacing, escaping and number formatting can differ, and the HMAC changes. Read the raw bytes once and keep them:

body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
if err != nil { /* 413 or 400 */ return }
if err := verify(body, r.Header); err != nil {
	http.Error(w, "invalid signature", http.StatusUnauthorized)
	return
}
var ev Event
if err := json.Unmarshal(body, &ev); err != nil { /* now it is safe to parse */ }

The traps are layered:

  • A router middleware that logs request bodies may read r.Body and leave it empty for your handler.
  • A framework helper that binds JSON into a struct has already consumed it.

If you must wrap, read once and put the bytes back:

body, _ := io.ReadAll(r.Body)
r.Body = io.NopCloser(bytes.NewReader(body))

For GitHub, check the content type you chose in the webhook settings. With application/x-www-form-urlencoded the body is payload=<urlencoded json> and the signature covers that whole string, so verify first and only then URL-decode the field. Choosing application/json removes that step.

Pitfall 2: a proxy or gateway that changes the body

Anything between the sender and your handler that re-serialises the body breaks every scheme. Caddy and nginx proxy request bodies as they are by default. The trouble comes from API gateways with transformation features, a WAF that normalises payloads, or a framework that decompresses and re-encodes. If verification fails only in production, compare the exact bytes: log their SHA-256 (never the content) at the edge and in the handler.

Pitfall 3: comparing with ==

Comparing signatures with == or bytes.Equal can return at the first differing byte, which in theory leaks how many leading bytes were right. Use hmac.Equal, which takes constant time for equal-length inputs, and compare decoded bytes, not hex strings:

got, err := hex.DecodeString(strings.TrimPrefix(header, "sha256="))
if err != nil || !hmac.Equal(got, mac.Sum(nil)) {
	return ErrInvalid
}

The same goes for any bearer token you check yourself, such as a token on a /metrics endpoint: use subtle.ConstantTimeCompare. When a header carries several signatures, check all of them without an early exit so timing does not reveal which entry matched.

Pitfall 4: ignoring the timestamp, or checking it wrong

A signature with no freshness check can be replayed by anyone who captured one request, for as long as the secret lives. Stripe and Standard Webhooks put a timestamp inside the signed content so an attacker cannot refresh it. You must compare it to your clock:

d := now.Sub(time.Unix(unix, 0))
if d > tolerance || d < -tolerance {
	return ErrInvalid
}

Both directions matter. Rejecting only old timestamps lets a request signed for the future wait. Five minutes is the common tolerance, and it assumes your server's clock is correct. If valid deliveries fail with a timestamp error, check timedatectl for clock synchronisation before you widen the window.

GitHub's scheme has no timestamp, so a captured request replays indefinitely. For GitHub you defend with dedupe on X-GitHub-Delivery and with idempotent handlers; see webhook idempotency and dedupe patterns. Note that a replay protection window and a dedupe store complement each other: the window bounds how long an old request is acceptable, and the store catches replays inside it.

Make now a parameter, not a call to time.Now() inside the verifier. Tests then freeze the clock at the timestamp of a known vector.

Pitfall 5: getting the secret's format wrong

Three variants cover most senders.

  • Plain string (GitHub): use the bytes as typed.
  • Stripe whsec_...: the whole string is the key. Do not strip the prefix and do not decode.
  • Standard Webhooks whsec_<base64>: strip the prefix and base64-decode the rest to get the key bytes.

Polar adds one more wrinkle. Its documentation says secrets created before 2026-09-08 use the UTF-8 bytes of the whole whsec_... string as the key, and newer ones follow the specification. If a spec-correct verifier rejects everything from an older Polar webhook, try the other treatment. The starter supports both by letting you list a secret twice, once prefixed with raw::

SIGNATURE_SCHEME=standard
WEBHOOK_SECRET=whsec_abc,raw:whsec_abc

Also never let an empty secret through. HMAC with an empty key is valid and computable by anyone, so a missing environment variable that becomes "" silently disables authentication. Refuse to start when no secret is configured, which is what the starter does.

Pitfall 6: rotating secrets with downtime

Secrets leak into chat logs and old laptops. Rotation must not drop deliveries. The receiver side is simple: accept a list of secrets and succeed if any one verifies.

for _, key := range keys {
	if err = Verify(key, body, headers, now, tol); err == nil {
		break
	}
}

The procedure for any provider is the same:

  1. Add the new secret to your list next to the old one and deploy.
  2. Switch the sender to the new secret.
  3. Wait for retries signed with the old secret to expire, which for Stripe can be days.
  4. Remove the old secret and deploy.

Rotating a secret without downtime

Four steps for rotating a webhook secret: add the new secret beside the old, switch the sender, wait for old retries to expire, then remove the old secret.1Add new secretkeep the oldone too2Switch sendernew signaturesstart3Wait forretriesold-secretretries expire4Remove oldsecretdeploy again

Stripe lets you roll a secret and keep the old one valid for a chosen period, up to 24 hours, signing with both meanwhile; its header then carries two v1= entries, and the Standard Webhooks header likewise can carry two space-separated signatures. Check every entry, not just the first. In the starter, WEBHOOK_SECRET=new,old does the receiver half, and because secrets are comma-separated a secret cannot itself contain a comma.

Keep secrets out of version control and out of logs. Load them from the environment or a systemd credential file, and do not print them in startup banners.

Pitfall 7: doing work before verifying

Verification is the first thing that happens after reading the body. Not after a database lookup, not after a JSON decode, not after "just logging the payload". A request that fails verification should store nothing and cost almost nothing. Return 401 with a terse body and no hint of why; a different message for "bad timestamp" and "bad signature" tells an attacker which part they got right.

Cap the work an unauthenticated request can cause as well. Limit the body size, limit the number of signatures you will examine in one header (sixteen is plenty), and set server timeouts so slow clients cannot hold connections; see Go HTTP timeouts for slow clients.

Pitfall 8: trusting a valid signature too much

A good signature establishes the sender. It says nothing about whether the event is new, whether it is in order, or whether the payload is something you should act on blindly. For a payment event, a cautious handler fetches the order from the provider's API by id and acts on that response instead of the webhook body. That costs one request and removes a whole class of tampering and staleness worries. It is also what lets you tolerate out-of-order delivery.

What each check protects against

Layers showing which defence answers which risk: the signature proves the sender, the timestamp bounds replays, dedupe catches repeats, and idempotent effects survive a crash.Signaturewho sent thisTimestamp windowhow old a captured request can beDedupe on event idhave we seen itIdempotent handlersafe if it runs twice

How to test the checks

Test against vectors someone else produced. The Standard Webhooks project's reference Go library publishes one, which the starter's tests use alongside GitHub and Stripe cases, including tampered body, wrong secret, stale and future timestamps, malformed headers and extra signature entries. For a manual check of a GitHub-style endpoint:

B='{"ref":"refs/heads/main"}'
SIG="sha256=$(printf '%s' "$B" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" | awk '{print $NF}')"
curl -i -X POST localhost:8080/webhook/github \
  -H "X-Hub-Signature-256: $SIG" -H 'X-Delivery-Id: t1' -d "$B"

Then change one character of $B and resend with the same signature, and expect a 401 and an empty queue. The code for the Standard Webhooks variant is in verifying Standard Webhooks signatures in Go, and the surrounding pipeline is in receiving signed webhooks in Go.

Mistakes we see

Copying one provider's helper for another. The Stripe and Standard Webhooks secret handling differ even though both start with whsec_.

Verifying in a different layer than the one that sees the raw body. Do it in the handler or a middleware that reads once and restores the body.

Shipping with a default or empty secret in development config. It reaches production more often than anyone admits.

Silent catch-all error handling. A verifier that returns nil on parse errors is an open door. Every parse failure is a rejection.

Logging the raw signature header or secret while debugging. It stays in the logs after you forget. Log outcomes, not credentials.

FAQ

Why does my signature fail only when the sender is real?

Your local test and the real request differ in the body bytes. Check content type, a middleware that read or re-encoded the body, a proxy that transformed it, or a secret in the wrong format. Compare a hash of the exact bytes at each hop.

Is HMAC-SHA256 enough, or do I need asymmetric signatures?

HMAC is sound when only you and the sender hold the secret. Asymmetric signatures, such as the v1a ed25519 variant in Standard Webhooks, let you verify with a public key so a leaked verifier cannot forge events. For a small app with one secret per sender, HMAC is a reasonable choice.

How do I verify GitHub webhooks without a timestamp?

Verify the HMAC and dedupe on X-GitHub-Delivery. Because replays are possible, make the handler idempotent, for example by recording the commit SHA already built.

Should I return different errors for different failures?

No. Return 401 for any verification failure and log the reason server-side. Detailed errors help an attacker more than a debugging developer.

What timestamp tolerance should I use?

Five minutes is the usual choice. Increase it only after confirming your clock is synchronised, since a wide window widens the replay opportunity.

If you want a verifier that already handles all three schemes, secret lists and these cases under test, the Go Webhook Receiver Starter includes one.

webhookssecuritygo