Card 05/ 09
Signing Webhooks: An HMAC and a Timestamp
A receiver checks a signature to know the event came from the sender, and a timestamp to know it is fresh.
A webhook URL is public, so anyone can post to it. The receiver trusts a delivery only if it carries a valid signature: an HMAC, a keyed hash computed over the delivery's timestamp and raw body with a secret only the sender and receiver know. A matching signature proves the sender made it; a recent timestamp proves it is not an old delivery replayed.
InputA delivery with headers webhook-timestamp 1791367200 and webhook-signature v1,K5o...; received at 1791367230
Read the timestamp and signature headers
- timestamp 1791367200read
- signature K5o...
Both are present, so the checks can start.
Check the timestamp against the clock
- age 30 spassed
- window 300 s
Thirty seconds is inside the five-minute window.
Compute the HMAC over timestamp and raw body
- 1791367200.{raw body}
- HMAC-SHA256 with the secret gives K5o...computed
The bytes are the body exactly as received, not re-serialised JSON.
Compare in constant time
- expected K5o...
- received K5o...passed
They match, so the sender produced this delivery.
ResultThe delivery is authentic and fresh, so the receiver stores the event and answers 200. A failure at any step is answered 400 or 401 and the event is dropped.
The shared secret. The sender issues it at registration, and the receiver stores it like a password. Rotating it means accepting both the old and new signatures for a short overlap.
The raw body. The signature is over bytes. Parsing the JSON and serialising it again changes spacing or key order, so the HMAC no longer matches. Verify before parsing.
Constant-time comparison. An ordinary string comparison stops at the first differing character, and the time it takes leaks how much of a forged signature was right. Use the language's constant-time compare, such as crypto.timingSafeEqual in Node.js or hmac.compare_digest in Python.
The timestamp. It is inside the signed bytes, so it cannot be altered. Refusing anything older than a few minutes means a captured delivery cannot be replayed later.
Conventions. Header names differ: Stripe uses Stripe-Signature, GitHub X-Hub-Signature-256, and the Standard Webhooks specification webhook-id, webhook-timestamp and webhook-signature. The values here are illustrative.
← → move through sections and cards