How webhook signatures work
A webhook is just an HTTP POST from someone else's server, and anyone can POST to your URL. To prove the request came from the provider, they compute an HMAC over the body with a secret only the two of you know and send it in a header. You recompute it and compare. The secret never travels; only the hash does.
Why “invalid signature” happens
- You hashed the parsed body, not the raw bytes. Frameworks that parse JSON before your handler runs (Express with
express.json(), Rails, Django) hand you an object; re-serialising it changes whitespace and key order and the hash no longer matches. Capture the raw body. - Wrong secret. Stripe has a different
whsec_per endpoint and per environment; GitHub's secret is per webhook; Slack's signing secret is not the bot token. - The provider signs more than the body. Stripe signs
timestamp.body; Slack signsv0:timestamp:body; Svix signsid.timestamp.body; Twilio signs the URL plus sorted form fields. The recipe for each is shown above when you pick it. - Encoding. Some providers send hex, some Base64; some prefix
sha256=. Compare the shapes. - Proxies. A proxy or CDN that re-encodes the body (gzip, charset) or a body size limit that truncates it will break the hash.
Comparing safely
In production, compare with a constant-time function (crypto.timingSafeEqual, hmac.compare_digest, hash_equals) and check the timestamp is recent (Stripe and Slack allow five minutes) to stop replays. This page is a debugger, not a reference implementation; it uses an ordinary comparison because timing attacks against your own browser are not a concern.
Questions
Is my secret safe here?
The HMAC is computed in your browser with WebCrypto and the page makes no network requests. Even so, use a test endpoint secret and rotate anything you paste into a web page if you want to be strict.
Why does GitHub send both X-Hub-Signature and X-Hub-Signature-256?
The first is HMAC-SHA1, kept for old integrations; the second is HMAC-SHA256 and is the one to verify. Both are over the raw body with the same secret.
The expected value matches except for case. Is that a failure?
Hex is case-insensitive in every provider’s verification code, so no; this tool compares case-insensitively for hex too. Base64 is case-sensitive.
Stripe says the timestamp is too old.
Stripe’s libraries reject signatures whose t= is more than 300 seconds from the current time, to prevent replay. Replaying a captured request later will fail for that reason even though the HMAC is correct; the tool notes the age.