HMAC (hash-based message authentication code) is a short tag computed from a message and a secret key with a hash function such as SHA-256. Anyone who holds the same key can recompute the tag and compare it. A match proves two things: the message was created by someone who knows the key (authenticity), and it hasn't been changed since (integrity). It's the mechanism behind almost every webhook signature.
$ printf '{"event":"ping"}' | openssl dgst -sha256 -hmac 'my-secret'
SHA2-256(stdin)= a4aa04fd005086c11e0953dd0aaa3306e2d8a9fbd9b7d333cc52ce210bdc67f6
How HMAC works
HMAC is defined in RFC 2104. Given a key K, a message m and a hash function H:
HMAC(K, m) = H( (K' ⊕ opad) || H( (K' ⊕ ipad) || m ) )
K' is the key padded to the hash's block size, and ipad and opad are fixed constants. You never implement this by hand. Every language ships it:
// Node.js
crypto.createHmac('sha256', secret).update(rawBody).digest('hex')
# Python
hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
// Go
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
sig := hex.EncodeToString(mac.Sum(nil))
The nested construction is why HMAC is safe even with hashes like SHA-256 that are vulnerable to length-extension attacks, where a naive SHA256(key + message) is not.
HMAC vs a plain hash vs a digital signature
| Plain hash (SHA-256) | HMAC | Digital signature (RSA, Ed25519) | |
|---|---|---|---|
| Needs a key | No | Shared secret | Private key to sign, public key to verify |
| Proves integrity | Only against accidents | Yes | Yes |
| Proves who sent it | No | Anyone with the shared key | Only the private-key holder |
| Speed | Fast | Fast | Slower |
Webhooks mostly use HMAC because it's fast, simple and every language supports it. The cost is that the sender and receiver share the same secret. Some specs, including Standard Webhooks, also define an asymmetric option for cases where receivers shouldn't hold a signing key.
How webhooks use HMAC
- The provider builds a signed string, such as the raw body, or
{timestamp}.{body}. - It computes
HMAC-SHA256(secret, signed_string), encodes it as hex or base64, and sends it in a header such asX-Hub-Signature-256(GitHub),Stripe-Signature(Stripe) orwebhook-signature(Standard Webhooks). - The receiver recomputes the HMAC over the bytes it received and compares in constant time.
Including a timestamp in the signed string, and rejecting old timestamps, prevents an attacker from resending a captured request. See webhook replay.
Common mistakes
- Comparing with
==. Use a constant-time comparison:crypto.timingSafeEqual(Node),hmac.compare_digest(Python),hmac.Equal(Go). - HMACing a re-serialized body. The tag covers exact bytes. Parsing and re-stringifying JSON changes them.
- Hex vs base64. The same HMAC encodes to different strings. Match the provider's encoding.
- Key encoding. Some providers give the key as base64 (Standard Webhooks
whsec_secrets) and expect you to decode it first. Others use the literal string. - Weak algorithms. Avoid HMAC-MD5 and prefer HMAC-SHA256 over HMAC-SHA1 for anything new.
- Short or guessable keys. Use at least 32 random bytes.
Try it
The HMAC calculator computes and compares HMACs in your browser. To check a full webhook against a provider's format, use the signature verifier. To verify HMAC on incoming webhooks before they're forwarded, see the HMAC authentication docs.
