Guide

A message authentication code (MAC) is a hash that also takes a secret key. Only a key holder can produce a valid tag. If the tag checks out, the message came from a key holder and wasn't changed. HMAC builds a MAC from any hash function. HMAC-SHA256 is the common choice.

The big idea

A plain hash isn't enough. Anyone can recompute it, so a changed message just comes with a new hash. A MAC adds a key, so an attacker who changes the message can't produce a matching tag.

Why not sha256(secret + message)? Because of length extension, covered in hash functions. HMAC hashes the key and the message twice, with the key mixed in each time. That double wrapping blocks length extension.

Concept

The shared key is the catch. Sender and verifier both hold it, so the verifier could have made the tag too. A MAC proves the message is genuine, but not which side made it. For that, you need a signature.

Walking through it

Here is a webhook verifier, in the style payment providers use for their callbacks. The header carries a timestamp t and a tag v1. The tag covers the timestamp and the raw body. The demo signs a body, then verifies it and a tampered copy.

Python
import hmac, hashlib, time

def verify_webhook(secret: bytes, raw_body: bytes, header: str, tolerance_s: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    ts = int(parts["t"])
    if abs(time.time() - ts) > tolerance_s:
        return False                                   # stale: blocks replay
    signed = f"{ts}.".encode() + raw_body              # sign the raw bytes
    expected = hmac.new(secret, signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts["v1"])  # constant-time compare

secret, body = b"whsec_test", b'{"id": "evt_1", "amount": 4200}'
ts = int(time.time())
sig = hmac.new(secret, f"{ts}.".encode() + body, hashlib.sha256).hexdigest()
header = f"t={ts},v1={sig}"
print(verify_webhook(secret, body, header))
print(verify_webhook(secret, body.replace(b"4200", b"1"), header))

Output:

True
False

Three details in that function are where real bugs live:

  1. Raw body. If you parse the JSON and re-serialize it, key order or whitespace can change, and the signature never matches. Engineers then disable verification to "fix" it. Verify the bytes you received.
  2. Timestamp in the signed data. Without it, one captured webhook can be replayed forever. The tolerance window limits replays.
  3. Constant-time comparison. == stops at the first differing byte, so response timing leaks how many leading bytes were right. Use hmac.compare_digest, crypto.timingSafeEqual or subtle.ConstantTimeCompare.

You'll also meet HMAC in signed S3 presigned URLs (SigV4 is a chain of HMACs), signed session cookies, HS256 JWTs, and the codes in MFA and passkeys.

Common mistakes

  • Verifying a re-serialized JSON body instead of the raw bytes.
  • Leaving the timestamp out of the signed data.
  • Comparing tags with ==.

Check yourself

  • Why can't an HMAC prove which side made a message? Answer: both sides hold the key, so the verifier could have computed the same tag.
  • Why does re-serializing the JSON break a genuine webhook? Answer: key order and whitespace change, so the bytes differ from what the sender signed.

Key takeaways

  • A MAC is a hash with a secret key. A matching tag means the message is genuine and unchanged.
  • HMAC wraps the hash twice, which blocks length extension. Always use it instead of hash(secret + message).
  • Sign the raw body, include a timestamp, and compare tags in constant time.
  • Use a signature instead of an HMAC when verifiers must not be able to sign.

Further learning

Next, see how the same key-based idea protects the data itself in AEAD and envelope encryption.