Verifying webhook signatures with HMAC
Why a webhook endpoint needs HMAC authentication, the standard provider signing pattern, the raw-body and constant-time gotchas, and what HMAC does not protect against.
영문 본문을 표시하고 있습니다. 번역은 준비 중입니다.
A webhook endpoint is a public URL. Stripe, GitHub, or your payment
processor POSTs events to it, but so can anyone who learns the path —
and the path leaks: it sits in browser network tabs, proxy logs, error
trackers, and the occasional pasted curl command. An endpoint that
acts on whatever JSON arrives is an endpoint that will eventually act
on a forged payment.succeeded event. Authentication is not optional;
it is the entire reason webhook signatures exist.
Why a plain hash is not enough
The naive defense is to hash the request body and compare. It does nothing. A hash like SHA-256 is a public function — no secret, no key. An attacker who forges a body can compute its SHA-256 exactly the way you would, attach it, and pass your check. There is no asymmetry, so there is no security.
What you need is a tag that only parties holding a shared secret can produce. That is HMAC.
HMAC basics
HMAC is a keyed hash. It takes a message and a secret key and produces a fixed-size tag:
HMAC-SHA256(secret, message)→ 32 bytes.
Without the secret, you cannot compute the tag and you cannot verify it. The provider and the receiver both hold the same secret, so both can compute the tag over a given body; nobody else can. An attacker who tampers with one byte of the body produces a tag that no longer matches, and cannot generate the correct one without the key.
The key word is shared. HMAC is symmetric — the same secret signs and verifies. That has consequences we will return to at the end.
The standard provider pattern
Real webhook providers converge on the same shape, and it is worth naming it precisely because the details are where implementations break:
- The provider computes
HMAC-SHA256over the raw request body, usually concatenated with a timestamp (e.g.timestamp + "." + body). - It sends the resulting tag, hex- or base64-encoded, in a request header, alongside the timestamp.
- The receiver reads the raw body and timestamp, recomputes the HMAC with the shared secret, and compares the result against the header value.
Stripe and GitHub both follow this pattern with HMAC-SHA256 and a signature header; Stripe additionally signs a timestamp to bind the request to a moment in time. The exact header names and encoding differ per provider, so read your provider's docs for the literal strings — but the cryptographic shape above is the constant.
Receiver pseudocode
SECRET = load_from_secrets_manager()
TOLERANCE = 300 # seconds
def verify(request):
raw_body = request.read_raw_bytes() # NOT parsed JSON
sig_header = request.header("X-Signature")
timestamp = request.header("X-Timestamp")
# 1. replay window
if abs(now() - int(timestamp)) > TOLERANCE:
reject("stale request")
# 2. recompute over the exact signed payload
signed_payload = timestamp + "." + raw_body
expected = hmac_sha256(SECRET, signed_payload) # hex string
# 3. constant-time compare
if not constant_time_equals(expected, sig_header):
reject("bad signature")
return parse_json(raw_body) # only now is it safe to parse
The raw-body gotcha
The single most common reason webhook verification fails in the field is signing the wrong bytes. The provider signed the exact byte sequence it sent. If your framework parses the JSON and you then re-serialize it to compute the HMAC, you get different bytes — key order changes, whitespace collapses, Unicode escapes normalize, trailing newlines vanish. The HMAC over re-serialized JSON will not match the HMAC over the original.
Capture the raw body before any JSON middleware touches it. In Express that means a raw body parser on the webhook route; in frameworks that eagerly parse, you often need a route-specific opt-out. Verify against those exact bytes, then parse.
Replay protection
A valid signed request is still valid if an attacker captures and
re-sends it. HMAC alone says "this body came from someone with the
secret" — it says nothing about when or how many times. Without
replay protection, a captured refund.created can be replayed until
the secret rotates.
The standard mitigation is the timestamp you are already signing:
- The provider includes a timestamp inside the signed payload.
- The receiver rejects requests whose timestamp is outside a tolerance window (five minutes is a common default).
The window is a genuine trade-off. Too tight and legitimate retries or clock skew between provider and receiver cause false rejections; too loose and the replay window widens. For stronger guarantees, record a nonce or the provider's event ID and reject duplicates — but that requires storage with its own retention and consistency questions. A timestamp window is the pragmatic floor; idempotency on your side (processing the same event ID at most once) is the durable answer.
Constant-time comparison
Once you have the expected tag and the received tag, comparing them
with == is a vulnerability. A normal string compare returns as soon
as it finds a mismatching byte. That means a wrong guess that matches
the first byte takes measurably longer than one that fails
immediately. An attacker who can time your responses can recover the
correct tag byte by byte — a classic timing attack.
Use a constant-time comparison that always examines the full length regardless of where the first difference is:
- Python:
hmac.compare_digest(a, b) - Node.js:
crypto.timingSafeEqual(bufA, bufB)(equal-length buffers) - Go:
hmac.Equal(macA, macB)
These exist specifically for this purpose. Reach for them instead of
== every time you compare a secret or a derived tag.
What HMAC does not give you
HMAC verification authenticates the sender and protects integrity. It does not give you several things people assume it does:
- Confidentiality. The body travels in the clear unless the connection is encrypted. HMAC does not hide anything. Use TLS; receive webhooks over HTTPS only.
- Non-repudiation. Because the secret is shared, a valid tag proves only that someone holding the secret produced it — which includes you, the receiver. You cannot prove to a third party that the provider, and not your own service, generated a given request. Symmetric authentication is mutual, not attributable.
Non-repudiation needs asymmetric signatures: the signer holds a private key, verifiers hold only the public key, and a valid signature can only have come from the private-key holder. That is the model behind RS256, ES256, and EdDSA — see JWT signing algorithms for the contrast. Most webhook providers deliberately choose symmetric HMAC anyway: it is faster, the secret distribution problem is trivial when there are exactly two parties, and non-repudiation is rarely the goal for a server-to-server event feed.
If the hash-versus-keyed-hash distinction is still fuzzy, hashing vs encryption vs encoding lays out why a bare hash carries no secret and a keyed hash does.
Summary checklist
A correct webhook verifier:
- Reads the raw body bytes, never the re-serialized JSON.
- Recomputes
HMAC-SHA256(secret, signed_payload)over the exact payload the provider signed, including any timestamp. - Compares tags with a constant-time function.
- Rejects requests outside a timestamp tolerance window, and treats event processing as idempotent.
- Runs over HTTPS and treats the secret like any other credential in your secrets manager, with a rotation path planned in advance.
To compute or check an HMAC tag by hand while debugging an integration — confirming you are signing the bytes you think you are — our HMAC generator takes a secret, a message, and a hash function and returns the tag in hex or base64.