Developer & encoding

How to Verify a Webhook Signature (and Why Yours Does Not Match)

How webhook signatures work, how to check one with HMAC-SHA256, and the four reasons a signature does not match: key encoding, algorithm, message bytes and comparison.

6 min readUpdated Sep 8, 2026

A webhook endpoint is a URL on the public internet that accepts POST requests. Anything can send it one, so your endpoint has no way of knowing a payment really succeeded unless the sender proves it. The proof is the signature header, and verifying it is the only thing standing between your handler and anyone who guesses the URL.

The mechanism is HMAC. You and the provider share a secret; the provider computes an HMAC over the exact request body and sends the result in a header, and you recompute it and check the two agree. Because only the two of you hold the secret, a matching tag means the body came from the provider and arrived unaltered. Here is how that works, a worked example you can reproduce, and the four reasons the numbers usually fail to line up.

What the signature actually is

HMAC takes two inputs, a secret key and a message, and returns a fixed-length tag. It is not the same as hashing the secret joined to the message. HMAC runs the hash twice, over two different paddings of the key, and that structure is what makes it safe: against a naive SHA256(secret + message), an attacker who never learns the secret can still append data to your message and compute a valid digest for the longer version. This is a length-extension attack, it works against SHA-256 and MD5 alike, and HMAC exists precisely to stop it. If you want the background on the hash functions underneath, see MD5 vs SHA-256.

Providers wrap the tag differently: GitHub sends sha256=<hex>, Stripe sends t=<timestamp>,v1=<hex> with the timestamp itself part of what gets signed, and Shopify sends bare Base64. Underneath they are the same operation in different spellings.

A worked example you can reproduce

Take the secret whsec_2f8a1d and this body, exactly 30 bytes with no trailing newline:

{"id":"evt_001","amount":2000}

HMAC-SHA256 of that body under that key is:

04d7b16b35f8babd0563096264230506406bbb4a2eded5668cdb97eaed46bdd3

Paste all three into the HMAC Generator - algorithm SHA-256, the secret as Text, the body in the message box - and you should get exactly that, along with the same value in uppercase hex, Base64 and Base64url. The command line agrees: printf '%s' '{"id":"evt_001","amount":2000}' | openssl dgst -sha256 -hmac "whsec_2f8a1d" prints the same 64 characters, as does Python's hmac.new(b'whsec_2f8a1d', body, hashlib.sha256).hexdigest().

Now change one invisible thing. Add a single newline to the end of the body and the tag becomes 6428eb25b250a7482e3dfd7e7df195fe48fd46d0ae07c20d42f3496ea63e02b9 - not similar, completely different. That is what makes HMAC useful and also what makes it maddening to debug: no partial credit, and no clue which end was wrong.

The four reasons a signature does not match

In practice, essentially every mismatch is one of four things.

  1. The key is being read differently at the two ends. This is the big one. A secret written 2f8a1d4b is eight characters of text, but it is also four bytes of hex, and the two readings are different keys. Over the body above, the text reading gives d35b699f0a743cbf9d54b42a7a43d064dc6777c10dafa1cb253ee4223368b8fd and the hex reading gives 6bd29bb767a595979ec6da58f122ed248af0627b19d578402be272d5b701fc95. Nothing about the secret tells you which one the provider means, so check the documentation - and note that openssl's -hmac flag always takes the key as text, so a hex key needs -mac hmac -macopt hexkey:2f8a1d4b instead.
  2. The algorithm is not the one you assumed. The length of the signature settles this without any guessing, because each hash has exactly one output size. A 64-character hex signature is SHA-256; 40 is SHA-1; 32 is MD5; 96 is SHA-384 and 128 is SHA-512. In Base64 the same digests are 44, 28, 24, 64 and 88 characters. If the header is 40 hex characters and you are computing SHA-256, stop there.
  3. The message bytes are not what you think. Sign the raw request body, before any JSON parsing. If your framework parses the body and you re-serialise it to sign, key order, spacing and unicode escaping can all shift and the tag moves with them. A trailing newline picked up from a file or a shell pipeline does the same, and so do CRLF line endings on a multi-line payload. Stripe and several others also sign a timestamp concatenated with the body rather than the body alone.
  4. The comparison itself is wrong. Hex is case-insensitive, so an uppercase digest and a lowercase one are the same value. Base64 and Base64url encode the same bytes with a different alphabet, and Base64 padding is often stripped. Decode both sides to bytes and compare those, rather than comparing strings.

Rather than working through that list by hand, paste the signature you were sent into the compare box on the HMAC Generator. It tries every algorithm, every reading of your secret and the usual transit changes, and names the combination that reproduces the signature - which tells you what to change in your code.

Compare in constant time

Once you have both tags, do not compare them with == or ===. An ordinary comparison returns as soon as it finds two bytes that differ, so a guess whose first byte is right takes measurably longer than one whose first byte is wrong. That timing difference is enough to recover a valid signature one byte at a time, without ever knowing the secret.

Every runtime ships a fixed-time comparison: crypto.timingSafeEqual in Node, hmac.compare_digest in Python, hash_equals in PHP, subtle.ConstantTimeCompare in Go, Rack::Utils.secure_compare in Ruby. Use one. Note that Node's timingSafeEqual throws if the buffers differ in length, so check the length first and reject rather than letting it raise.

What a valid signature does not tell you

A correct signature proves the body came from someone holding the secret and was not modified. It does not prove the request is fresh. Anyone who captures a signed request can send the identical bytes again, and the signature will still verify - that is a replay. This is why providers sign a timestamp alongside the body and expect you to reject anything older than a few minutes, and why the timestamp has to be inside the signed data rather than sent beside it.

Nor does it make your handler idempotent. Providers deliberately re-send events they did not get a 2xx for, so the same correctly signed event will arrive more than once. Record the event id and ignore ones you have already processed.

  • Read the raw body, verify, then parse - never the other way round.
  • Reject on a missing or malformed signature header, not just a mismatched one.
  • Reject anything with a timestamp outside a short tolerance window.
  • Return 2xx quickly and do the real work asynchronously, or the provider will time out and retry.
  • Keep the secret out of your repository, and rotate it if it has ever been pasted somewhere.

Checking it without sending anything anywhere

A signing secret is a credential, so where you test one matters. The HMAC Generator computes everything inside your browser tab and uploads nothing. Even so, a live production secret is worth rotating rather than pasting into any tool, and a throwaway value is enough when you only need the shape of a signature. For a plain unkeyed digest there is the Hash Generator, and the Base64 Encoder will decode a Base64 signature into its bytes.

Frequently asked questions

How do I know whether my secret is text, hex or Base64?
Usually only the provider's documentation says, because the secret itself gives no clue - 2f8a1d4b is a perfectly good eight-character string and a perfectly good four-byte hex value, and the two produce entirely unrelated tags. Two hints help. A secret that is all hex digits with an even length is often meant as hex, and one that ends in = is almost certainly Base64. If the documentation is silent, compute the tag under all three readings and see which reproduces a signature you have already received; the compare box on the HMAC Generator does exactly that sweep for you.
Why does my signature change when the JSON looks identical?
Because HMAC signs bytes, not meaning. If you parse the request body into an object and then re-serialise it before signing, your serialiser may order keys differently, put spaces after colons, escape non-ASCII characters as \u sequences, or drop a trailing newline that was in the original. Every one of those changes the bytes and therefore the tag, while leaving the JSON semantically the same. Capture the raw body as it arrives, verify against that, and only parse afterwards.
Is HMAC-SHA1 or HMAC-MD5 safe if my provider still uses one?
It is not the emergency it sounds like. MD5 and SHA-1 are broken for collision resistance, which is what disqualifies them for certificates and digital signatures, but HMAC is a different setting and no practical forgery against HMAC-MD5 or HMAC-SHA1 is known. That is why they still appear in older payment, telecom and AWS Signature V2 integrations, and why the HMAC Generator supports them. For anything you control, choose HMAC-SHA256; for an integration you do not control, keep verifying what the provider sends and treat the algorithm as a reason to ask about their upgrade path.