Skip to content

← All writing

HMAC vs. Hashing: How to Verify Webhooks and API Requests

· by Andergrove Software

A plain hash of a request body proves nothing about who sent it. Anyone who can see the body can compute the same SHA-256, so a bare hash cannot tell a real webhook from a forged one. The fix is HMAC: a hash combined with a secret key that only you and the sender share. If the signature matches, the message is both unchanged (integrity) and from someone holding the secret (authenticity).

This is how Stripe, GitHub, Shopify and Slack sign their webhooks. Here is why a keyed hash is necessary, how the verification flow works end to end, and the one comparison mistake that quietly reopens the hole you just closed. You can generate and check HMACs in the HMAC generator as you follow along, all in your browser.

Why a plain hash isn't enough

Suppose a provider sends you JSON plus a SHA-256 of that JSON. An attacker intercepts the request, rewrites the body, recomputes the SHA-256 of their new body, and sends it on. Your check passes, because a hash is a public function: no secret is involved, so anyone can produce a valid hash for any content.

A bare hash detects accidental corruption, not deliberate tampering by someone who can also recompute it. To prove a message came from a party you trust, the signature has to depend on a secret only that party (and you) know.

HMAC: a hash with a key

HMAC (Hash-based Message Authentication Code) mixes your secret key into the hashing process in a specific, secure way, hashing the key and message together with inner and outer padding. The result, written HMAC-SHA256(key, message), is a fixed-length tag. Two properties matter:

  • Integrity. Change one byte of the message and the tag changes completely, thanks to the avalanche effect of the underlying hash (see SHA-256 explained).
  • Authenticity. Without the secret key you cannot produce a valid tag, so a matching tag proves the sender knew the key.

A plain hash gives you the first property but not the second. That second property is the whole point of webhook verification.

The webhook verification flow

When a provider sends you a webhook:

  1. You and the provider agree on a shared secret, a signing key you copy from their dashboard.
  2. The provider computes HMAC-SHA256(secret, raw_body) and sends it in a header, such as Stripe-Signature or X-Hub-Signature-256.
  3. Your endpoint recomputes the HMAC over the raw body using the same secret and compares it to the header value.
  4. If they match, the request is genuine. If not, reject it with a 400.

Two practical gotchas. Sign the raw bytes, not a re-serialized JSON object, because re-encoding can change spacing and break the signature. And include any timestamp the provider sends as part of the signed payload, so an old captured request cannot be replayed later.

The comparison footgun: use constant time

Once you have the expected tag and the received one, do not compare them with ==. A normal string comparison returns as soon as it finds a mismatched character, so it takes slightly longer the more leading characters are correct. An attacker can measure that timing to guess a valid signature one character at a time. Use a constant-time comparison instead: crypto.timingSafeEqual in Node, hmac.compare_digest in Python. Both always examine the whole value, leaking no timing information.

Verify it in code

Node:

const crypto = require('crypto');

function verify(rawBody, signature, secret) {
  const expected = crypto
    .createHmac('sha256', secret)
    .update(rawBody)            // raw bytes, not parsed JSON
    .digest('hex');
  const a = Buffer.from(expected);
  const b = Buffer.from(signature);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Python:

import hashlib, hmac

def verify(raw_body: bytes, signature: str, secret: str) -> bool:
    expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)

Try it

Generate an HMAC of a sample body with a secret in the Andergrove HMAC Generator, then change one character of the body and watch the tag change completely. It supports SHA-256 and other hashes and runs entirely in your browser, so your secret never leaves the page. For the hash underneath, see SHA-256 explained; for another place signed tokens show up, see how JSON Web Tokens work.