Verifying Webhook Signatures by Hand: HMAC-SHA256 in the Browser with HashForge

Written by

in

,

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 in HashForge without pasting a production secret into some random website.

This post is about the boring, load-bearing part of webhooks that 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)

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. If the two match, the message is authentic and untampered. If they don’t, you reject it with a 401 and move on.

The reason this 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 perfect for a sanity check. Secret is It's a Secret to Everybody, body is Hello, World!. The expected signature is:

757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17

Open HashForge, switch to the HMAC panel, drop the secret into the key field and the body into the message field, pick SHA-256. 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.

I like doing this in HashForge specifically because it runs entirely in the browser. The HMAC is computed with the Web Crypto API (crypto.subtle.sign), so your secret never leaves the tab. Check the Network panel if you don’t believe me — 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
  }
);

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. If HashForge’s output doesn’t match and you’re sure the body is right, this is the next thing to check.

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);
}

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...

The signed payload is t + "." + body, not the body alone. So you concatenate the timestamp, a literal dot, and the raw body, then HMAC-SHA256 that whole string with your signing secret. To reproduce it in HashForge, paste 1699999999.{your raw body} into the message field.

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 in HashForge 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.

If you want the byte-level view of what’s actually being hashed, the Web Crypto API is worth understanding — it’s the same primitive HashForge uses under the hood. And if you’re inspecting the JWTs some webhooks carry instead of HMAC headers, the offline JWT reader covers that failure mode.

Keep the secret on paper, not in a note app

One habit worth building: webhook signing secrets are long-lived credentials, and they end up scattered across .env files, CI variables, and password managers. I keep the master copy of anything I can’t regenerate in a small hardware-backed spot rather than a cloud note. A cheap encrypted USB key for offline secret backups has saved me twice when a password manager sync went sideways. Full disclosure: that’s an Amazon affiliate link.

The point of verifying signatures at all is that you don’t get to be sloppy with the secret. Compute the HMAC in the browser, compare in constant time, hash the raw bytes, and check the timestamp. Four rules, and your webhook endpoint stops trusting strangers.

You can try the HMAC verification yourself right now in HashForge — pick SHA-256, paste a secret and a message, and watch the signature appear without a single network request.


Join https://t.me/alphasignal822 for free market intelligence.

📧 Get weekly insights on security, trading, and tech. No spam, unsubscribe anytime.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *

Also by us: StartCaaS — AI Company OS · Hype2You — AI Tech Trends