HMAC Explained: How a Shared Secret Proves a Webhook Is Real
How HMAC mixes a secret key into a hash like SHA-256 to prove message integrity and authenticity, why it beats a plain hash, and how to verify Stripe and GitHub webhooks locally.
HMAC Explained: How a Shared Secret Proves a Webhook Is Real
The first time a webhook silently stopped reaching my handler, I spent an afternoon adding print statements to a payment integration before I realized the request was being rejected at the signature check — not by my code, by my framework. The provider had signed the request body with HMAC, my secret was technically correct, and the signatures still didn't match. The culprit turned out to be three bytes of whitespace. That afternoon is exactly why I want to explain HMAC properly: once you understand what the tag actually proves, those failures become two-minute fixes instead of half-day mysteries.
A hash tells you nothing about who sent it
A hash function like SHA-256 takes any input and produces a fixed-length digest. Change one byte of the input and the digest changes completely. That makes a plain hash great for detecting accidental corruption — if you download a file and its SHA-256 matches what the publisher posted, you know the bytes arrived intact. You can recompute that digest with a hash tool or a file hash calculator and compare.
But a plain hash is unkeyed, and that is its fatal weakness for authentication. Anyone can compute SHA-256 of anything. If a server only checks that hash(message) matches some posted digest, an attacker who can intercept and replace the request can simply recompute the hash over their own tampered payload. The digest will match their forged message perfectly. A bare hash proves a message wasn't accidentally mangled. It proves nothing about who produced it.
That is the gap HMAC fills.
How HMAC mixes a secret into the hash
HMAC stands for Hash-based Message Authentication Code. The key idea — and the one concrete point worth memorizing — is this: HMAC mixes a secret key into a hash like SHA-256, so only someone who holds the key can produce or verify the resulting code. A plain hash cannot authenticate anything because anyone can compute it; HMAC can, because the secret gates the computation.
Mechanically, HMAC doesn't just glue the key onto the message and hash it (that naive approach has real attacks). Instead it hashes the message twice with the key folded in through two padded constants. The standard formula is:
HMAC(K, m) = H( (K ⊕ opad) ‖ H( (K ⊕ ipad) ‖ m ) )
Here H is the underlying hash (SHA-256, SHA-512, and so on), K is your secret key, m is the message, and ipad/opad are fixed padding bytes. The nested structure is what makes HMAC robust. The practical consequence is simpler: without the secret K, you cannot compute the tag, and you cannot verify someone else's tag either. Knowing the key is the whole point.
Because the function is deterministic, the same algorithm, key, and message always yield the same tag. That property is what makes verification possible — and what makes mismatches diagnosable.
A worked example, conceptually
Say a service shares the secret key s3cr3t-key with you (as UTF-8 text) and sends you the message order_id=4821&amount=1999. To authenticate it with HMAC-SHA256, the sender computes:
tag = HMAC-SHA256(key = "s3cr3t-key", message = "order_id=4821&amount=1999")
The result is a 32-byte value (SHA-256 produces 256 bits = 32 bytes), which gets written out as a 64-character lowercase hex string, or sometimes as a shorter base64 string. The sender attaches that tag to the request. On your side, you run the exact same computation with the exact same key and the exact same message bytes. If your tag equals theirs, two things are true at once: the message wasn't altered in transit (integrity), and the sender knew the secret (authenticity). You can reproduce this step by step in the HMAC generator — paste the message, type the key, pick HMAC-SHA256, and read the tag back in both hex and base64.
Note the two encodings. The 32 raw bytes are the truth; hex and base64 are just two ways of writing those same bytes down. A "mismatch" between a hex tag and a base64 tag is no mismatch at all — it's the same value in two alphabets.
Verifying Stripe and GitHub webhooks
Webhook signatures are where HMAC shows up in everyday work. The pattern is identical across providers: they compute an HMAC over the raw request body using a secret only you and they share, then send the tag in a header.
- Stripe sends a
Stripe-Signatureheader containingt=<timestamp>,v1=<hmac>. The signed payload is the timestamp concatenated with the raw body, and the secret (whsec_...) is UTF-8 text. To verify by hand, reconstruct the signed string, run HMAC-SHA256, and compare the hex againstv1. - GitHub sends
X-Hub-Signature-256: sha256=<hex>. The message is the exact JSON payload bytes, the secret is your UTF-8 webhook secret, and the algorithm is HMAC-SHA256. Recreate the header by prependingsha256=to the hex output.
When a check fails, work through the usual suspects in order. First, the message bytes: HMAC is computed over the body exactly as sent. If your framework re-parses the JSON and re-serializes it with different whitespace or reordered keys, or if a proxy adds a trailing newline, you are signing a different message and the tag will differ for an entirely legitimate cryptographic reason. Always sign the byte-for-byte payload, not a reconstructed object.
Second, the key encoding. A secret can be UTF-8 text, raw hex (a 32-byte key written as 64 hex characters), or base64. The same key bytes interpreted three different ways produce three different tags. A 64-character hex string decodes to 32 bytes under "hex" but to 64 bytes under "UTF-8" — a single wrong setting is the classic reason a webhook check fails while "the key is right." If you ever need to convert a base64 secret to inspect its bytes, a base64 encoder makes the decoding step explicit.
HMAC versus passwords versus tokens
It helps to place HMAC next to its neighbors so you reach for the right one.
HMAC authenticates a message between two parties who share a secret. It is fast, deterministic, and symmetric — the same key signs and verifies. That symmetry is exactly why you should never store a signing key carelessly: leak it and every signature it ever made becomes forgeable.
That is different from hashing a password, where you specifically want a slow, salted, one-way function — that's the job of a bcrypt generator, not HMAC. And it's different again from a signed token: a JWT encoder building an HS256 token uses HMAC-SHA256 internally to sign the header.payload segment, so understanding HMAC directly demystifies why a JWT is "valid" or not.
Why local computation matters
A secret signing key is the crown jewel of a webhook integration. If it leaks, an attacker can forge any message and your server will accept it as authentic. So pasting your key and payload into a random online tool that round-trips them through a server is precisely the wrong move.
HMAC computes locally without any cloud help. Modern browsers expose crypto.subtle.sign, a native implementation that decodes your key, imports it, and signs the message entirely inside the tab. Nothing is uploaded, nothing is logged, nothing lands in analytics. When you verify a signature, sanity-check a JWT, or build a signing header, the key and message never need to leave your machine — and they shouldn't. Treat any tool that sends them off-device as a leak waiting to happen.
Understanding HMAC turns webhook debugging from guesswork into a checklist: confirm the bytes, confirm the key encoding, confirm the output encoding. Three questions, three checks, and the mystery is gone.
Made by Toolora · Updated 2026-06-13