The guide
A webhook signature checks bytes, not meaning, and most failed checks are bytes that quietly changed
Why a receiver signs the raw body, what HMAC actually computes, how timestamps stop replays, and why the final comparison must take the same time every time.
- 60 bytes signed in the Standard Webhooks test vector
- 0x36 and 0x5C the inner and outer pad bytes of HMAC
- 300 s replay window in the Standard Webhooks Go library
- Dec. 31, 2030 when NIST stops using SHA-1 in its protocols
Read the full guide Fold the guide
A webhook is a polite kind of interruption. Something happens on one system, an order ships or a build finishes, and that system makes an HTTP request to an address you gave it in advance. The term was coined in 2007, from the programmer’s word “hook”, and the idea is simply a user-defined HTTP callback. The trouble is the address. Anyone who learns it can send a request to it, and the request will look exactly like the real thing.
So the receiver has to ask a question that HTTP does not answer on its own: did this request really come from the sender I trust, and has nobody altered it on the way? The Standard Webhooks specification puts it bluntly. Webhooks are just HTTP requests from an unknown source, so checking their authenticity is a requirement, not a refinement.
A keyed fingerprint of the exact bytes
The usual answer is a shared secret and a message authentication code. Both sides hold the same random secret. The sender runs the secret and the message through HMAC and puts the result in a header. The receiver does the same calculation on what arrived and compares. If the two values agree, the message was produced by someone holding the secret and was not changed afterwards. HMAC gives that assurance without the public key infrastructure that digital signatures need, which is why it became the common choice.
HMAC itself is a short recipe, defined in RFC 2104 and standardised by NIST in FIPS 198-1. Take a hash function such as SHA-256, which works on blocks of 64 bytes. Pad the key with zero bytes to the block length; a key longer than a block is hashed first. Mix the padded key with a block of the byte 0x36, put the message after it and hash. Then mix the key with a block of 0x5C, put the first hash after it and hash again. The output is as long as the hash: 32 bytes for SHA-256 and 64 for SHA-512, whose block is 128 bytes.
Two passes, told apart only by the pad bytes 0x36 and 0x5C. That is all the machinery. What matters for debugging is the input, because HMAC has no notion of meaning. It sees a sequence of bytes and nothing else. Change one byte anywhere and the output changes completely, with no hint of where or how much.
The 32 bytes then have to travel in a header, which means text. Senders write them in hexadecimal or in base64, both defined in RFC 4648. Hex is the case-insensitive form, so “AB” and “ab” carry the same byte, yet a receiver that compares the two strings as text will call them different. Base64 packs the same 32 bytes into 44 characters. Mixing the two up is one of the quieter ways a correct signature gets rejected.
Why the exact bytes matter
Here is the worked example, taken from the test suite of the Standard Webhooks reference libraries, and you can reproduce it in the checker above. The secret is whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw. The prefix only labels it; the rest is base64 for 24 random bytes, and those bytes are the key. The message id is msg_p5jXN8AQM9LWM0D4loKWxJek, the timestamp is 1614265330 and the body is {“test”: 2432232314}, with one space after the colon. The signed content is the id, a full stop, the timestamp, another full stop and the body: 60 bytes. HMAC-SHA256 over those bytes, written in base64, is g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE= and the header carries it as “v1,” followed by that value.
Now remove the single space after the colon. The JSON means exactly the same thing. The signature becomes Vif40peJBP7Iyl0XGmu61n4MwdrcHov5CFREBpE0svs= instead, which shares nothing with the first one.
That is the most common failure in practice, and the specification names it. A receiving framework parses the body as JSON before the check runs, and the code then serialises it again to verify it. RFC 8259 says whitespace between JSON tokens is insignificant, and it notes that parsers differ on whether they keep the order of object members. Both statements are true about meaning and irrelevant to HMAC. A re-serialised body can drop spaces, add indentation, escape characters differently or reorder keys, and every one of those is a different message to the hash.
Line endings and trailing newlines do the same damage more quietly. HTTP/1.1, in RFC 9112, ends each header line with a carriage return and line feed, but the body is just a run of octets counted by Content-Length. A body signed with CRLF line endings and read back through something that normalises them to LF will not verify. Neither will a body that lost or gained a final newline in a text editor or a test fixture.
The rule that follows is simple to state and easy to break: the receiver must sign the raw bytes it received, before any parsing, decoding or tidying. Parse afterwards, and only once the signature matches.
Timestamps, replays and rotation
A valid signature proves who sent a message, not when. Someone who captures one genuine delivery can send it again tomorrow, and the signature will still be valid, because nothing in the bytes has changed. That is a replay attack, and the classic defence is to put the time inside what gets authenticated and to accept only messages whose time is within a reasonable tolerance of your own clock.
Standard Webhooks does exactly that. The sender includes a webhook-timestamp header in whole seconds since 1970, and the signed content is the message id, the timestamp and the body, joined by full stops. Because the timestamp is signed, an attacker cannot refresh it. The reference library written in Go rejects a timestamp more than five minutes old or more than five minutes in the future, which is a 300 s window either way. The message id has its own job: it stays the same across retries, so a receiver can store it and refuse to process the same event twice.
Time units are a trap of their own. A sender that signs milliseconds while the header says seconds, or the reverse, produces a signature that never matches, and a receiver that reads milliseconds as seconds sees a timestamp tens of thousands of years in the future. A ten-digit number is seconds in this era; a thirteen-digit one is almost certainly milliseconds.
Secrets also need to change from time to time, and RFC 2104 already recommended refreshing keys periodically. The awkward moment is the switch, when some senders and receivers have the new key and some still have the old. Standard Webhooks handles it by allowing several signatures in one header, separated by spaces. During a rotation the sender signs with both keys, and the receiver accepts the message if any signature checks out. Sending a signature made with an old key weakens nothing, because a forger still needs a valid one.
The final comparison, and what a checker cannot tell you
The last step looks trivial: compare the value you computed with the value you received. The obvious code compares character by character and stops at the first difference. That early exit leaks information. A guess that matches the first ten characters takes slightly longer to reject than one that matches none, and an attacker who can time thousands of requests can recover a valid signature a little at a time, which the specification describes as turning the receiver into a signing oracle.
The defence is a comparison that always examines every byte and only then reports the result. In the Web Crypto API the verify method does the whole calculation and comparison for HMAC itself, and a hand-written check should fold every difference into one value before deciding.
There is also a different way to avoid sharing a secret at all. The specification defines an asymmetric scheme, tagged “v1a”, that uses Ed25519 from RFC 8032. The sender holds a 32-byte private key and publishes a 32-byte public key; signatures are 64 bytes, and a receiver that only verifies never holds anything an attacker could use to sign. Web Crypto exposes Ed25519 through the same sign and verify methods, and the checker tests that the browser really supports it before offering it.
One more caution concerns the hash. HMAC-SHA1 still appears in older webhook schemes. NIST has said that anyone relying on SHA-1 for security should migrate and that it will stop using SHA-1 in its remaining protocols by Dec. 31, 2030. New integrations should use SHA-256.
The checker on this page works through all of this on your own device. It signs the exact bytes you give it, shows them as text and as hex, compares the result with a received header in constant time, and checks the timestamp against your clock. When the values differ, it re-signs the message with each common mistake applied, one at a time, and names the first one that reproduces the received signature. The usual suspects are a re-serialised body, CRLF against LF, a trailing newline, a secret read as text instead of decoded base64, hex letter case, the wrong separator, or a timestamp in milliseconds.
Its limits are real, and worth stating plainly. It cannot receive a webhook, because that needs a server listening on the internet, and this page runs none. It sends nothing anywhere. Its diagnosis only knows the causes it tries: if none of them reproduces the received value, the most likely explanation is a different secret, but it cannot prove that, and two changes at once will look the same as a wrong key. And a constant-time comparison written in JavaScript describes the algorithm, not a measured guarantee about the timing of every engine that runs it. The checker narrows a frustrating search to a short list. The fix still belongs in the receiving code, where the bytes are read.
Sources
Every source consulted for this guide. Links checked October 21, 2026.
- RFC 2104: HMAC: Keyed-Hashing for Message Authentication IETF, RFC Editor
- FIPS 198-1, The Keyed-Hash Message Authentication Code (HMAC) NIST Computer Security Resource Center
- FIPS 180-4, Secure Hash Standard (SHS) NIST Computer Security Resource Center
- RFC 4231: Identifiers and Test Vectors for HMAC-SHA-224, HMAC-SHA-256, HMAC-SHA-384, and HMAC-SHA-512 IETF, RFC Editor
- RFC 2202: Test Cases for HMAC-MD5 and HMAC-SHA-1 IETF, RFC Editor
- RFC 4648: The Base16, Base32, and Base64 Data Encodings IETF, RFC Editor
- RFC 8032: Edwards-Curve Digital Signature Algorithm (EdDSA) IRTF, RFC Editor
- RFC 8259: The JavaScript Object Notation (JSON) Data Interchange Format IETF, RFC Editor
- RFC 9112: HTTP/1.1 IETF, RFC Editor
- standard-webhooks/spec/standard-webhooks.md at main Standard Webhooks specification 1.0.0, Apache License 2.0
- standard-webhooks/libraries/go/webhook.go at main Standard Webhooks reference library, Apache License 2.0
- SubtleCrypto: sign() method MDN Web Docs, CC BY-SA 2.5
- SubtleCrypto: verify() method MDN Web Docs, CC BY-SA 2.5
- SubtleCrypto: importKey() method MDN Web Docs, CC BY-SA 2.5
- NIST Retires SHA-1 Cryptographic Algorithm National Institute of Standards and Technology
- Webhook Wikipedia, CC BY-SA 4.0
- HMAC - Wikipedia Wikipedia, CC BY-SA 4.0
- Timing attack Wikipedia, CC BY-SA 4.0
- Replay attack Wikipedia, CC BY-SA 4.0