A webhook endpoint is a public URL that accepts a POST from anyone who finds it.
Nothing about the request proves it came from your payment provider rather than a
script that guessed the path. Signature verification is how you tell a genuine event
from a forged one before you act on it — activate a subscription, ship an order, credit
an account. Get it wrong and you either reject real events or trust fake ones. This
guide covers the mechanics that matter, the one mistake that causes most failures, and
working code in three languages.
Why signatures matter
Assume your endpoint URL is not secret. It sits in provider dashboards, proxy logs, and
browser history; it leaks. An attacker who knows it can send a crafted invoice.payment_succeeded and, if you process unverified bodies, mark an unpaid order
as paid. A signature closes that hole: the provider computes a keyed hash of the request
body using a secret only the two of you share, and sends the hash in a header. You
recompute it with the same secret. If they match, the body is authentic and unmodified.
If they do not, you reject with 401 and stop.
Signatures are authentication and integrity, not encryption. The body is still plain text on the wire — use HTTPS for confidentiality. The signature only tells you that whoever sent this body holds the shared secret and that no byte changed in transit.
How HMAC signing works
Almost every provider uses HMAC — a hash (usually SHA-256) keyed with the shared secret. The provider does roughly this:
signature = HMAC_SHA256(secret, request_body) # sent as hex or base64 in a header You repeat the calculation on the bytes you received and compare. Because the secret is an input to the hash, an attacker without it cannot produce a matching signature for their own body, and cannot alter a real body without invalidating the signature. The whole scheme rests on two things being exactly right: the secret, and the bytes you hash.
Rule one: hash the raw request body
This is the single most common cause of signature failures, so it comes first. You must
compute the HMAC over the exact bytes the provider signed — the raw request body,
untouched. The moment a framework parses JSON and your code re-serialises it, the bytes
change: key order shifts, whitespace collapses, 1.0 becomes 1, unicode escapes
normalise. The re-serialised body is equivalent JSON but different bytes, so its
HMAC differs and every verification fails.
The practical consequence: capture the raw body before any JSON middleware runs, verify against those raw bytes, and only parse afterwards. Every example below reads the raw body first and parses second.
Working code
These are a generic HMAC-SHA256 pattern. Providers differ in header name, encoding, and whether they prefix the value or fold in a timestamp — adapt the header lookup and format to your provider using the table further down. The verification core is the same everywhere.
Go
crypto/hmac ships hmac.Equal, which compares in constant time. Read the raw body
with io.ReadAll and cap it.
package webhooks
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
)
// verify checks an HMAC-SHA256 signature sent as a hex string in a header.
// Adapt the header name and encoding to your provider.
func verify(secret []byte, next func([]byte)) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1 MiB cap
if err != nil {
http.Error(w, "cannot read body", http.StatusBadRequest)
return
}
mac := hmac.New(sha256.New, secret)
mac.Write(body)
expected := mac.Sum(nil)
got, err := hex.DecodeString(r.Header.Get("X-Signature"))
if err != nil || !hmac.Equal(got, expected) {
http.Error(w, "invalid signature", http.StatusUnauthorized)
return
}
next(body) // body is the verified raw bytes; parse it inside next.
w.WriteHeader(http.StatusOK)
}
} Node.js and TypeScript
Use express.raw (or your framework’s raw-body option) so req.body is a Buffer of
the original bytes, not parsed JSON. crypto.timingSafeEqual is the constant-time
comparison, and it throws on length mismatch — so guard the length first.
import express from 'express';
import crypto from 'node:crypto';
const app = express();
// Raw body for this route only; a JSON parser must not run before it.
app.post('/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
const secret = process.env.WEBHOOK_SECRET!;
const signature = req.header('X-Signature') ?? '';
const expected = crypto
.createHmac('sha256', secret)
.update(req.body) // req.body is a Buffer of the raw bytes
.digest('hex');
const a = Buffer.from(signature);
const b = Buffer.from(expected);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).send('invalid signature');
}
const event = JSON.parse(req.body.toString('utf8')); // safe to parse now
// ... process event
res.sendStatus(200);
}); Python
hmac.compare_digest is the constant-time comparison. In Flask, request.get_data() returns the raw bytes before any JSON parsing.
import hashlib
import hmac
import os
from flask import Flask, request, abort
app = Flask(__name__)
@app.post("/webhooks")
def webhooks():
secret = os.environ["WEBHOOK_SECRET"].encode()
raw = request.get_data() # raw bytes, before any JSON parsing
expected = hmac.new(secret, raw, hashlib.sha256).hexdigest()
signature = request.headers.get("X-Signature", "")
if not hmac.compare_digest(expected, signature):
abort(401)
event = request.get_json() # safe to parse now
# ... process event
return "", 200 Timestamped signatures and replay attacks
A plain body signature has a gap: it stays valid forever. If an attacker captures one real signed request, they can resend the identical bytes tomorrow and it verifies — because it is a genuine, correctly signed message. That is a replay attack.
Providers close it by signing a timestamp together with the body and sending the
timestamp alongside the signature. You verify the signature over timestamp.body, then
reject anything whose timestamp is outside a tolerance window — commonly around five
minutes. A captured request replayed later fails the freshness check even though its
signature is valid.
import time
TOLERANCE_SECONDS = 300 # 5 minutes
def verify_timestamped(secret: bytes, raw: bytes, timestamp: str, signature: str) -> bool:
# Reject stale messages so a captured request cannot be replayed later.
if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
return False
signed = timestamp.encode() + b"." + raw
expected = hmac.new(secret, signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature) The tolerance is a trade-off: too tight and legitimate deliveries fail when clocks drift or the provider queues a retry; too loose and the replay window widens. Keep your server clock synced with NTP — clock skew is a frequent cause of “signature valid yesterday, invalid today” reports. A freshness check reduces replays but is not a substitute for idempotency: the provider’s own retries deliver the same event legitimately, so your handler must tolerate duplicates regardless.
Constant-time comparison, and why == leaks timing
Never compare signatures with ==, strcmp, or an ordinary string equality check. Most
equality routines short-circuit — they return the instant they hit a differing byte. So
a signature that matches the first ten bytes takes measurably longer to reject than one
that differs at byte one. An attacker who can measure your response time can exploit that
difference to recover a valid signature byte by byte, without ever knowing the secret.
Constant-time comparison always examines every byte, so timing reveals nothing about how
much matched. That is what hmac.Equal (Go), crypto.timingSafeEqual (Node) and hmac.compare_digest (Python) give you, and why every example above uses them. It is a
one-line difference in the code and the entire point of the exercise — use them.
Provider schemes differ — do not assume one format
There is no universal webhook signature format. Header names, encodings, and what exactly gets signed vary per provider. Treat any single provider’s scheme as one example, not the standard, and read the provider’s own documentation before you write the header parsing.
| Provider | Header | What is signed | Encoding |
|---|---|---|---|
| Stripe | Stripe-Signature | t=<timestamp>,v1=<sig> over timestamp.body | hex |
| GitHub | X-Hub-Signature-256 | body, value prefixed sha256= | hex |
| Shopify | X-Shopify-Hmac-Sha256 | body | base64 |
| Slack | X-Slack-Signature | v0:<timestamp>:<body>, value prefixed v0= | hex |
The differences are exactly the things that break naive code: GitHub prefixes the value
with sha256= (compare against the whole prefixed string, or strip it consistently);
Shopify encodes base64 while the others use hex; Stripe and Slack fold a timestamp into
the signed string, so hashing the bare body will never match. When in doubt, consult the
provider’s documentation — for example Stripe’s webhook signing guide or GitHub’s webhook validation docs —
and check your work against captured requests. See the Stripe and GitHub integration notes for
the specifics HookWatch uses.
Rotating a signing secret
Secrets leak — into logs, a compromised laptop, an old employee’s password manager. You should be able to rotate one without a flag day. The mechanism is an overlap window: generate a new secret, then for a period accept a request if it verifies against either the old or the new secret. Once every sender is emitting the new signature, retire the old one.
def verify_during_rotation(raw: bytes, signature: str, secrets: list[bytes]) -> bool:
# Accept if any active secret verifies. Try newest first.
for secret in secrets:
expected = hmac.new(secret, raw, hashlib.sha256).hexdigest()
if hmac.compare_digest(expected, signature):
return True
return False The window has to be long enough to cover in-flight retries and any caching on the sender’s side, and short enough that a leaked old secret is not accepted indefinitely. HookWatch signing-secret rotation uses a 24-hour overlap: after you rotate, both the old and new secrets are accepted for 24 hours, then the old one stops working.
Common verification mistakes
Most signature bugs are one of these:
- Hashing a parsed body instead of the raw bytes. The number-one cause; covered above.
- The wrong secret. A test-mode secret checked against live events, or the reverse. They are different keys and will never match.
- Encoding mismatch. The provider sends base64 but you decode as hex, or you compare hex text to a raw digest. Match the provider’s encoding exactly.
- The header prefix. Forgetting that a value is
sha256=...orv0=...and comparing your bare hash to the prefixed string. - A trailing newline added to the secret when it was pasted into an environment
variable or read from a file.
whsec_abc\nis notwhsec_abc. - Clock skew breaking a timestamp tolerance on a server whose clock has drifted.
- A proxy mutating the body between the edge and your handler.
When verification fails and you cannot tell which of these it is, the fastest path is to look at the exact request that failed — the raw body, the signature header, the timestamp — and recompute by hand. That is easier when something already captured those bytes; see working a failed delivery step by step and the signature mismatch error reference.
Verifying at the edge
You can also verify before the request reaches your service. A HookWatch endpoint can
check inbound signatures using either its own scheme (X-Hookwatch-Signature,
HMAC-SHA256) or a custom scheme configured to match your provider: header name,
algorithm (SHA-1, SHA-256, or SHA-512), encoding (hex or base64), and an optional prefix
like sha256=. Provider secrets are stored encrypted, and rotation uses the 24-hour
overlap window described above. Because the endpoint records the full raw request for
every delivery, a signature that fails is something you can inspect byte for byte rather
than reproduce from guesses.
Whether you verify in your handler or at the edge, keep the invariant fixed: hash the raw body, fold in the timestamp if the provider signs one, compare in constant time, and let a rotation overlap window carry you across secret changes. Once you can verify events, the next thing to get right is testing them locally before they ever reach production.