DEV Community

Max
Max

Posted on • Originally published at orthogonal.info

Verifying Webhook Signatures by Hand: HMAC-SHA256 and Why Yours Keeps Failing

A webhook fired at 2am, my handler 500'd, and the vendor's dashboard just said "delivery failed." No body, no signature, no clue. When I finally caught the payload, the first thing I needed to know was: is this actually from them, or is someone POSTing garbage at my endpoint?

That question is answered by one line of crypto — an HMAC-SHA256 signature — and you can check it by hand without pasting a production secret into some random website. This is about the boring, load-bearing part of webhooks nobody documents well: how the signature header is computed, why your comparison keeps failing, and how to verify one manually when a delivery breaks.

What the signature header actually is

Every serious webhook provider signs the request body. GitHub sends X-Hub-Signature-256. Stripe sends Stripe-Signature. Shopify sends X-Shopify-Hmac-Sha256. Different header names, same idea:

signature = HMAC-SHA256(secret, raw_request_body)
Enter fullscreen mode Exit fullscreen mode

The provider and you both know a shared secret. They hash the exact bytes of the body with that secret and ship the result in a header. You recompute the same hash on your side. Match = authentic and untampered. No match = reject with a 401 and move on.

Why it matters: your webhook URL is public the moment you register it. Anyone who finds it can POST a fake "payment succeeded" event. Without signature verification, your app will happily believe them.

Verifying a GitHub signature by hand

GitHub's own docs use a concrete example, which makes it a perfect sanity check. Secret is It's a Secret to Everybody, body is Hello, World!. The expected signature is:

757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17
Enter fullscreen mode Exit fullscreen mode

Run HMAC-SHA256 with that key and message and you get exactly that hex string. That's the whole verification. GitHub prefixes it with sha256= in the header, so the real value on the wire is sha256=757107ea... — strip the prefix before comparing.

Do this client-side (the Web Crypto API's crypto.subtle.sign keeps the secret in the tab — check the Network panel, there are no outbound requests). Pasting a webhook secret into a server-side "online HMAC generator" is the kind of thing that ends up in someone's access logs.

The three reasons your comparison fails

Manual verification exposes the bugs that silently break webhook handlers. In order of how often they've bitten me:

1. You hashed the parsed body, not the raw body. This is the big one. Frameworks love to parse JSON for you. But JSON.stringify(JSON.parse(body)) is not the original bytes — key order changes, whitespace vanishes, unicode gets re-escaped. The signature is over the exact bytes the provider sent. In Express you need the raw buffer:

app.post('/webhook',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const sig = req.get('X-Hub-Signature-256');
    const expected = 'sha256=' + hmacSha256(secret, req.body);
    // req.body is a Buffer here, not a parsed object
  }
);
Enter fullscreen mode Exit fullscreen mode

If your handler works in tests but fails in production, this is almost always why — a body parser upstream mangled the bytes before you hashed them.

2. Wrong key encoding. Most providers treat the secret as a UTF-8 string. Some — a few payment and banking APIs — give you a hex or base64 secret that must be decoded to raw bytes first. Hashing the literal hex characters instead of the decoded bytes gives a completely different result.

3. Non-constant-time comparison. Once the bytes are right, don't compare signatures with ===. A naive string compare returns early on the first mismatched character, which leaks timing information an attacker can measure. Use a constant-time compare:

const crypto = require('crypto');
function safeEqual(a, b) {
  const ba = Buffer.from(a), bb = Buffer.from(b);
  if (ba.length !== bb.length) return false;
  return crypto.timingSafeEqual(ba, bb);
}
Enter fullscreen mode Exit fullscreen mode

Stripe adds a timestamp — and so should you

Stripe's Stripe-Signature header isn't just the HMAC. It looks like:

t=1699999999,v1=5257a869e7ecebeda32affa62cdca3fa...
Enter fullscreen mode Exit fullscreen mode

The signed payload is t + "." + body, not the body alone. You concatenate the timestamp, a literal dot, and the raw body, then HMAC-SHA256 that whole string with your signing secret.

The timestamp exists to stop replay attacks. Someone who captures a valid signed request can't resend it a day later, because you also check that t is within a few minutes of now. If you're building your own webhook sender, copy this pattern — sign the timestamp alongside the body and reject stale ones.

When to reach for manual verification

You don't do this on every request — your code handles the happy path. Manual HMAC checking earns its keep in exactly three moments:

  • First integration. Before you trust your verification code, confirm it against a known payload. Recompute the signature and diff it against what your handler produced. If they disagree, your handler is wrong, not the provider.
  • A specific delivery failed. Grab the raw body and the signature header from the provider's delivery log, recompute by hand, and you'll immediately see whether it's a body-encoding bug or a genuinely bad signature.
  • Rotating secrets. After changing a signing secret, verify one real event manually before you trust the pipeline again.

Four rules

Compute the HMAC on the raw bytes, compare in constant time, decode the key to the right encoding, and check the timestamp. Four rules, and your webhook endpoint stops trusting strangers.

I wrote the full walkthrough — including the byte-level view of what's actually being hashed and a browser-based way to reproduce each provider's signature without a network request — over on my blog.

What's the weirdest webhook signature scheme you've had to reverse-engineer? A few payment APIs out there do genuinely cursed things with the payload ordering.

Top comments (0)