Hash-based Message Authentication Code
HMAC (RFC 2104, 1997; later codified as FIPS 198-1, 2008) is a cryptographic construction that turns any cryptographic hash function
H(such as SHA-256 or SHA-384) plus a secret keyKinto a keyed message authentication code. The output is a short tag — typically 32 bytes for HMAC-SHA-256 — that proves two things about a message: (1) the data was not modified in transit (integrity), and (2) the sender possessed the secret key (authenticity, in the symmetric sense).
HMAC is one of the most-deployed cryptographic primitives on the Internet. It powers the HS256 algorithm in JSON Web Tokens, the signature verification on every Stripe / GitHub / Shopify webhook (see Webhook Delivery System Design), the AWS Signature Version 4 (SigV4) request signing scheme, the PRF in TLS 1.2’s key-derivation, the IPsec authentication payload, the OATH-based HOTP and TOTP one-time-password generators (Google Authenticator), and countless internal API-signing schemes.
This note treats HMAC at three levels: the construction itself (the famous double-hash with ipad/opad), the security argument (why this construction defeats length-extension attacks that broke earlier “naive” MAC designs), and the practical pitfalls (timing-attack-prone comparison, key-rotation patterns, truncation, key-reuse-across-protocols). The worked example at the end walks through a Stripe webhook signature in Python and Go.
1. Definition and Origins
A Message Authentication Code (MAC) is a small tag accompanying a message that the recipient can use to verify both that the message has not been tampered with and that whoever sent it knows a shared secret. A MAC is the symmetric-key cousin of a digital signature: a digital signature uses asymmetric keys (private to sign, public to verify) and is publicly verifiable; a MAC uses a single shared secret key and is verifiable only by parties holding that key.
Before HMAC, the obvious construction was H(K || m) — concatenate the key and the message, hash. This is broken against many widely deployed hash functions because of length-extension attacks (see §3): for Merkle–Damgård hashes (MD5, SHA-1, SHA-256, SHA-512), an attacker who knows H(K || m) and len(K || m) can compute H(K || m || padding || m') for any chosen m' without knowing K. The attacker can therefore forge a valid MAC on an extended message.
The fix was published in:
- Bellare, Canetti, and Krawczyk (1996), “Keying Hash Functions for Message Authentication” (CRYPTO ‘96 paper) — the original construction and proof.
- RFC 2104 (Krawczyk, Bellare, Canetti, 1997) — the IETF standard form.
- FIPS 198-1 (NIST, 2008) — the US federal standard, slightly tightened.
The construction works with any iterated hash; the standardized instances are HMAC-SHA-1 (now deprecated), HMAC-SHA-256, HMAC-SHA-384, and HMAC-SHA-512.
2. The Construction
The HMAC formula, given a hash function H with block size B bytes (B = 64 for SHA-256, B = 128 for SHA-512) and output size L bytes (L = 32 for SHA-256), takes a key K and message m:
HMAC(K, m) = H( (K' ⊕ opad) || H( (K' ⊕ ipad) || m ) )
with:
K'= the key normalized to exactlyBbytes:- if
len(K) > B:K' = H(K)padded with zeros toBbytes. - if
len(K) < B:K' = Kpadded with zeros toBbytes. - if
len(K) == B:K' = K.
- if
ipad= the byte0x36repeatedBtimes (“inner pad”).opad= the byte0x5CrepeatedBtimes (“outer pad”).⊕= bytewise XOR.||= byte concatenation.H= the underlying hash function (e.g., SHA-256).
So HMAC is two passes through H: an inner hash producing an intermediate digest, then an outer hash. To unpack the symbols carefully:
K'. The key is normalized to the hash’s block size. If the user supplied a 16-byte key with SHA-256, it is right-padded with 48 zero bytes to fill 64 bytes. If they supplied a 200-byte key, it is hashed to 32 bytes, then right-padded with 32 zero bytes to fill 64 bytes. The reason:ipad/opadare exactly one block long, so XORing them againstK'requiresK'be one block.K' ⊕ ipad. XORing the key bytes with0x36 0x36 0x36 .... This produces an “inner key” that is one block long.K' ⊕ ipad || m. Prepend the inner key block to the message. This is the input to the inner hash.H(K' ⊕ ipad || m). The intermediate digest,Lbytes (32 for SHA-256). It is not yet the MAC; it is still vulnerable to length-extension if returned alone.K' ⊕ opad. A different “outer key” formed by XORingK'with0x5Crepeated.H(K' ⊕ opad || H(...)). Hash the outer key block prepended to the intermediate digest. This is the final MAC tag.
The constants 0x36 and 0x5C are chosen so that ipad and opad differ in many bit positions. Writing them out bit by bit: 0x36 = 0011 0110 and 0x5C = 0101 1100. XOR’ing the two gives 0110 1010 = 0x6A, which has Hamming weight 4 out of 8 bits — meaning the two constants disagree in exactly half their bit positions. Half-disagreement is the maximum a single byte pair can achieve without one being the bitwise complement of the other (complements would have Hamming distance 8, but then K' ⊕ opad = NOT(K' ⊕ ipad), a relationship an attacker could exploit). Hamming distance 4 sits at the sweet spot: the inner-key block K' ⊕ ipad and outer-key block K' ⊕ opad look statistically independent to the compression function while remaining “completely unrelated” in any algebraic sense an attacker could leverage.
Bellare, Canetti, and Krawczyk’s 1996 paper §5 explicitly notes that the choice “is not critical for the security of HMAC” — any pair of distinct byte constants whose XOR has reasonable Hamming weight would yield a secure construction. The specific values 0x36/0x5C are an arbitrary but conventional choice that has been audited and tested across three decades of cryptanalysis without finding weakness. Changing them would create an incompatible-but-equally-secure variant; preserving them is purely a matter of interoperability with RFC 2104 and FIPS 198-1.
2.1 Key Normalization in Detail
The key-normalization step (K → K') is where many bugs originate, because the spec mandates deterministic handling of three distinct cases and any deviation produces incompatible tags. Walking each case in detail:
- Key shorter than block size (
len(K) < B). The key is right-padded with zero bytes to fill exactlyBbytes. For example, with HMAC-SHA-256 (B = 64), a 16-byte keyK = 0xAA AA ... AA(sixteen0xAAbytes) becomesK' = 0xAA AA ... AA 00 00 ... 00— 16 bytes of key, then 48 zero bytes. This is the most common case in practice; nearly every webhook secret, AWS access secret, or JWT signing key fits inside the block size. - Key exactly equal to block size (
len(K) == B). No transformation;K' = K. This is the “natural” case the construction was designed around. - Key longer than block size (
len(K) > B). The key is first hashed toLbytes, then right-padded with zero bytes to fillBbytes. For HMAC-SHA-256, a 100-byte key becomesK' = SHA-256(K) || 00 ... 00(32 bytes of hash followed by 32 zero bytes). Crucially, two distinct keys whose SHA-256 happens to collide would produce the same HMAC tag — but since SHA-256 is collision-resistant, this is computationally infeasible.
A subtle and frequently overlooked corollary: a long random key gives no more security than L bytes (the hash output size). If you pass a 1024-byte random key to HMAC-SHA-256, it is hashed to 32 bytes first; you effectively have a 32-byte (256-bit) key. Going beyond L bytes of key entropy is wasted. Going below L bytes weakens the construction by exactly the entropy gap. This is why the canonical recommendation for HMAC-SHA-256 keys is exactly 32 bytes of cryptographically random data — matching the hash output, no shorter, no point being longer.
2.2 Block Size: SHA-256 vs SHA-512
A trap for the unwary: the block size B is not the output size L. The Merkle–Damgård family has a block size that determines how input is chunked through the compression function, distinct from the digest length.
| Hash | Output L (bytes) | Block size B (bytes) | HMAC tag size |
|---|---|---|---|
| MD5 | 16 | 64 | 16 |
| SHA-1 | 20 | 64 | 20 |
| SHA-224 | 28 | 64 | 28 |
| SHA-256 | 32 | 64 | 32 |
| SHA-384 | 48 | 128 | 48 |
| SHA-512 | 64 | 128 | 64 |
| SHA-512/256 | 32 | 128 | 32 |
For HMAC-SHA-512, the key is padded to 128 bytes, not 64. A 32-byte secret used with HMAC-SHA-512 gets 96 trailing zero bytes — not a security problem (96 trailing zeros is still a uniquely identifying inner-key block), but a fact that catches developers comparing manual implementations against library output.
HMAC-SHA-512 is sometimes preferred over HMAC-SHA-256 on 64-bit platforms because SHA-512’s compression function is actually faster per byte: it processes 1024-bit blocks (128 bytes) at a time with 64-bit arithmetic, where SHA-256 processes 512-bit blocks with 32-bit arithmetic. On x86-64 / ARM64 hardware without SHA-NI acceleration, HMAC-SHA-512 outperforms HMAC-SHA-256 by roughly 1.5×. On hardware with SHA-256 acceleration (Intel SHA Extensions, ARMv8 Cryptography Extensions, IBM POWER8+), the ranking flips and SHA-256 is faster. The honest recommendation: use HMAC-SHA-256 unless benchmarking demonstrates otherwise; the security difference (256-bit vs 512-bit output) is irrelevant in practice since 2^128 is already infeasible for the foreseeable future.
2.3 Why Two Passes?
A naive HMAC alternative would be H(K || m || K) or even H(K || m). The two-pass nested structure exists specifically to defeat length-extension and to permit a security proof that reduces HMAC’s strength to the underlying hash’s properties (specifically, to the hash’s compression function being a pseudo-random function).
The Bellare-Canetti-Krawczyk 1996 proof shows: HMAC is a secure MAC (PRF, in fact) as long as the underlying hash’s compression function is a PRF, even if the full hash is not collision-resistant. This is a remarkably strong result — it means HMAC-MD5 and HMAC-SHA-1 are still secure as MACs (though deprecated for performance and migration reasons) even though the underlying MD5 and SHA-1 are broken for collision resistance. The proof was tightened in Krawczyk 2000.
3. Length-Extension Attacks: The Specific Threat HMAC Defeats
To understand why the two-pass construction is necessary, examine what would go wrong with H(K || m) when H is a Merkle–Damgård hash.
Merkle–Damgård (the construction underlying MD5, SHA-1, SHA-256, SHA-512) processes input in fixed-size blocks. Internally, the hash maintains a state vector (e.g., 256 bits for SHA-256), processes one block at a time updating the state via a compression function, and at the end pads the final partial block with a length-encoded padding then runs one more compression. The output is the final state.
Crucially, the output is the internal state. Given H(x) for any x, an attacker can:
- Reconstruct the internal state at the moment hashing of
xfinished (it equals the output). - Extend the input by appending
padding(len(x)) || yfor any choseny. - Run the compression function on this new suffix starting from the reconstructed state.
- The result equals
H(x || padding(len(x)) || y)— a valid hash of an extended message, computed without knowingx.
For the naive MAC H(K || m): if the attacker observes a valid (m, T) pair where T = H(K || m), and knows len(K) (often easy — keys are 32 bytes by convention), they can compute T' = H(K || m || padding || m') for any m'. Now (m || padding || m', T') is a valid MAC pair under the same key. The attacker has forged a MAC.
This is not theoretical — it is the basis of the Flickr API forgery in 2009, where Flickr’s signature scheme used MD5(secret || params). Length-extension let attackers forge signatures and call the API as any user.
HMAC defeats this because the attacker cannot length-extend the inner hash. The output of H(K' ⊕ ipad || m) is fed back into another H(K' ⊕ opad || ...), and the attacker doesn’t know K' ⊕ opad, so they cannot compute the outer hash on an extended input.
(Hash functions designed after Merkle–Damgård — SHA-3 / Keccak, BLAKE2, BLAKE3 — are not vulnerable to length-extension at all; for those, simpler MACs like H(K || m) would actually be safe. But HMAC was designed before SHA-3 existed and remains the standard because of compatibility.)
4. Security Properties
The standard formal property is EUF-CMA: Existential UnForgeability under Chosen-Message Attack. Given an adversary who can query an oracle MAC_K(·) on chosen messages and receive their tags, the adversary cannot produce a new (m*, T*) pair (with m* not previously queried) such that T* = MAC_K(m*), except with negligible probability.
HMAC achieves EUF-CMA assuming:
- The underlying hash’s compression function is a PRF (pseudo-random function).
- The key is uniform random and at least as long as
L(the hash output size). For HMAC-SHA-256, this means at least 32 bytes (256 bits) — keys shorter than this still “work” in the construction but offer reduced security, and keys shorter than ~16 bytes (128 bits) are brute-force-feasible.
The output size of an HMAC tag equals the hash output size. HMAC-SHA-256 is 32 bytes (256 bits); brute-forcing requires 2^256 guesses, infeasible. Truncated HMACs (e.g., HMAC-SHA-256 truncated to 8 bytes) are 64-bit and might be feasible for an online oracle in extreme threat models — best practice is to never truncate below 128 bits.
Difference from Digital Signatures
| HMAC | Digital signature (RSA, ECDSA) | |
|---|---|---|
| Key model | Single shared secret | Key pair: private (sign), public (verify) |
| Verifier needs | The same secret as the signer | Only the public key |
| Non-repudiation | No (signer & verifier indistinguishable) | Yes (only signer has private key) |
| Speed (sign) | Microseconds | Microseconds (ECDSA) to milliseconds (RSA-2048) |
| Speed (verify) | Microseconds | ~Same as sign |
| Tag size | 32 B (HMAC-SHA-256) | 64 B (ECDSA P-256), 256 B (RSA-2048) |
| Use case | Server-to-server with trusted endpoints, webhooks | Public verification (signed software, JWTs distributed to many verifiers) |
For JSON Web Tokens, HS256 is HMAC; RS256 and ES256 are digital signatures. Use HMAC when the signing-and-verifying parties trust each other to hold the secret; use signatures when verifiers must be a broad untrusted set.
4.1 Non-Repudiation and Why It Matters
The non-repudiation property of digital signatures — and HMAC’s lack of it — is the practical fault line between the two. With an RSA-signed JWT, only the holder of the private key could have produced the signature; the public key proves to a third party that a specific signing entity emitted the token. A court or auditor can present the signed token as evidence: “Acme Inc. signed this.” With an HMAC-SHA-256 token, the same secret that produced the tag is held by every verifier, so any verifier could have forged a tag indistinguishably. The verifier cannot prove to a third party that the signer (not the verifier itself) produced the message, because the verifier has the same key.
In practice this means: HMAC is appropriate when the signer–verifier relationship is bilateral and trusted (server signs token, same server verifies it on the next request; webhook provider signs payload, single receiver verifies). HMAC is inappropriate for scenarios where a verifier must convince a third party of authenticity — code signing, document signing, blockchain transactions, identity attestations consumed by parties beyond the issuer’s audience. Those need asymmetric signatures specifically for non-repudiation.
A second practical consequence: when a JWT issuer (an OAuth identity provider, say) distributes tokens that many relying parties verify, HMAC is operationally awkward. The issuer must share the secret with every relying party, multiplying the attack surface — any compromised relying party can forge tokens that any other relying party will accept. RS256/ES256 distributes only the public key, which is non-secret and freely publishable, so a compromised relying party cannot mint tokens. This is why production OIDC (OpenID Connect) providers default to RS256 or ES256 and reserve HS256 for tokens that the issuer alone consumes.
4.2 Speed Comparison and When Speed Matters
HMAC-SHA-256 on modern hardware runs at roughly 1–3 GB/s per core with hardware acceleration (Intel SHA-NI, ARMv8 Crypto Extensions) or 400–700 MB/s without. The signing and verification costs are nearly identical (both are one HMAC computation). Per-message overhead is in the single-digit microseconds for typical webhook-sized payloads (1–10 KB).
By contrast, RSA-2048 signing takes roughly 0.5–1 millisecond per signature on modern x86-64; RSA-2048 verification is much faster, around 30 microseconds (the public exponent is typically 65537, a small odd value, while signing uses the full private exponent). ECDSA-P256 signing is ~50–100 microseconds and verification ~150–300 microseconds. Ed25519 is faster still: signing ~20 µs, verification ~50 µs.
Net: HMAC is roughly 10× faster than ECDSA signing and 100× faster than RSA signing. For internal service-to-service authentication at high request rates (10k+ RPS per node), this difference is meaningful — HMAC keeps signature overhead under 1% of CPU; RSA can push it to 10–20%. For low-rate operations (one webhook per second), the speed difference is irrelevant and the architectural fit (non-repudiation? trust model?) dominates.
4.3 Wire-Level vs Application-Level HMAC
A subtle distinction worth noting: HMAC can be applied at different layers of the stack.
- Wire level: TLS 1.2 cipher suites of the form
AES_128_CBC_SHA256use HMAC-SHA-256 inside the TLS record layer to authenticate each TLS record (the famous Encrypt-then-MAC vs MAC-then-Encrypt debate; RFC 7366 standardizes Encrypt-then-MAC for TLS). The HMAC here is invisible to the application; the TLS library handles it. - Application level: A webhook signature
X-Hub-Signature-256is computed by the application before TLS handles the request and verified by the application after TLS decrypts. The HMAC survives across TLS termination — important for systems where a load balancer terminates TLS and forwards plaintext HTTP to backends, since the application-level HMAC still proves the upstream sender’s authenticity.
The two layers serve different threat models. Wire-level HMAC defends against an in-network attacker who can modify TCP segments. Application-level HMAC additionally defends against a compromised TLS terminator, a malicious reverse proxy, or replay of a captured request after TLS session expiry.
5. Where HMAC Is Deployed
5.1 JWT HS256
The HS256 algorithm in JSON Web Tokens is HMAC-SHA-256. The token’s signing input — BASE64URL(header) || "." || BASE64URL(payload) — is HMAC’d with the issuer’s secret, and the resulting tag is Base64URL-encoded as the third segment. Verification recomputes the HMAC and constant-time-compares.
5.2 Webhook Signatures
Every major SaaS that emits webhooks signs the payload with HMAC and includes the tag in a header. The receiver re-computes and compares. The Webhook Delivery System Design note covers the broader architecture; here are the specifics:
- Stripe (docs): signs the string
t=<unix timestamp>.<request_body>with HMAC-SHA-256, sendsStripe-Signature: t=1731000000,v1=<hex_hmac>. The timestamp prevents replay (receiver rejects if older than 5 minutes by default). - GitHub (docs): signs the request body with HMAC-SHA-256, sends
X-Hub-Signature-256: sha256=<hex_hmac>. - Shopify (docs): HMAC-SHA-256 of the body, base64-encoded, in
X-Shopify-Hmac-Sha256. - Twilio: HMAC-SHA-1 of the URL + sorted parameters, in
X-Twilio-Signature. (SHA-1 is deprecated for collisions but still secure as a MAC; Twilio has not migrated yet.)
5.3 AWS Signature Version 4
AWS SigV4 signs every authenticated AWS API call with a multi-stage HMAC. The full construction is worth unpacking, because it is one of the most-used HMAC deployments on the planet and exemplifies several patterns at once: chained key derivation, scoping by date/region/service, and canonicalization of an HTTP request into a signable string.
Stage 1: Build the canonical request. AWS defines a deterministic textual representation of the request — the method, URI path (URL-encoded), sorted query string, sorted-and-lowercased signed headers, the list of signed-header names (host;x-amz-date;...), and the SHA-256 hash of the request body, joined with newlines:
CanonicalRequest =
HTTPRequestMethod + '\n' +
CanonicalURI + '\n' +
CanonicalQueryString + '\n' +
CanonicalHeaders + '\n' +
SignedHeaders + '\n' +
HexEncode(SHA256(RequestPayload))
Canonicalization is non-optional: any difference in URL-encoding, header capitalization, or whitespace produces a different canonical request and therefore a different signature, which AWS rejects with SignatureDoesNotMatch. This is the most common SigV4 implementation bug.
Stage 2: Build the string-to-sign. A wrapper that includes the algorithm, ISO-8601 timestamp, and credential scope:
StringToSign =
"AWS4-HMAC-SHA256" + '\n' +
ISO8601_Timestamp + '\n' +
CredentialScope + '\n' +
HexEncode(SHA256(CanonicalRequest))
CredentialScope = "<YYYYMMDD>/<region>/<service>/aws4_request"
The credential scope (e.g., 20260511/us-east-1/s3/aws4_request) commits the signature to a specific date, region, and service.
Stage 3: Derive the signing key via chained HMAC. This is the canonical worked example of HMAC-as-KDF:
kSecret = "AWS4" + SecretAccessKey # prefix the literal "AWS4" to the secret
kDate = HMAC-SHA256(kSecret, YYYYMMDD) # bind to date
kRegion = HMAC-SHA256(kDate, region) # bind to region
kService = HMAC-SHA256(kRegion, service) # bind to service
kSigning = HMAC-SHA256(kService, "aws4_request") # final signing key
Each line takes the previous derived key as the HMAC key and the next scope component as the HMAC message. The output (32 bytes) becomes the key for the next stage. After four HMAC computations, you have kSigning, a 32-byte key bound to (date, region, service).
The brilliance of this design: a kSigning value, if leaked, is useless outside the specific date+region+service it was scoped to. An attacker who somehow extracts the signing key from an S3 SDK call on 2026-05-11 cannot use it to sign DynamoDB requests, or to sign S3 requests dated 2026-05-12, or to sign S3 requests against eu-west-1. Compromise blast radius is bounded to a day’s worth of one service in one region.
Stage 4: Compute the signature.
signature = HexEncode(HMAC-SHA256(kSigning, StringToSign))
The result is the hex-encoded signature that goes into the Authorization: AWS4-HMAC-SHA256 Credential=..., SignedHeaders=..., Signature=<sig> header.
This pattern — using HMAC as a chain of key-derivation operations — predates and parallels HKDF (described below). It’s a self-rolled KDF that AWS shipped before HKDF was standardized. The same construction, with the same security properties, is what HKDF formalizes.
5.4 HOTP / TOTP One-Time Passwords
HOTP and TOTP — the algorithms behind Google Authenticator, Authy, and most TOTP apps — are HMAC-based:
HOTP(K, counter) = truncate(HMAC-SHA-1(K, counter))
TOTP(K, time) = HOTP(K, floor(time / 30))
The shared secret K is the QR-code-encoded value scanned from the authenticator app. Each 30-second window produces a 6-digit code derived from HMAC-SHA-1 of the counter. (TOTP allows SHA-256/SHA-512 too, but most implementations stuck with SHA-1 — secure as MAC, even if SHA-1 is broken for collisions.)
5.5 TLS 1.2 PRF
TLS 1.2’s pseudo-random function (used to derive session keys from the master secret) is an HMAC-based construction called P_hash:
P_hash(secret, seed) = HMAC(secret, A(1) || seed) || HMAC(secret, A(2) || seed) || ...
A(0) = seed
A(i) = HMAC(secret, A(i-1))
TLS 1.3 replaced this with HKDF (RFC 5869), which is itself HMAC-based.
5.6 HKDF: HMAC-based Key Derivation
HKDF (RFC 5869), authored by Krawczyk (the same Krawczyk behind HMAC’s original 1996 paper), is the standard “extract-then-expand” key-derivation function used by TLS 1.3, Signal protocol, WireGuard, Noise framework, and most modern protocols that need to turn a shared secret into multiple session keys. Its entire construction is built on HMAC:
HKDF-Extract(salt, IKM):
PRK = HMAC-Hash(salt, IKM) # PRK is a "pseudo-random key" of L bytes
HKDF-Expand(PRK, info, L_out):
T(0) = empty string
T(1) = HMAC-Hash(PRK, T(0) || info || 0x01)
T(2) = HMAC-Hash(PRK, T(1) || info || 0x02)
T(3) = HMAC-Hash(PRK, T(2) || info || 0x03)
...
OKM = T(1) || T(2) || ... truncated to L_out bytes
Symbol walkthrough: IKM is the input keying material (a shared secret, perhaps from a Diffie-Hellman exchange or a password). salt is optional context (random or a protocol identifier). PRK is a uniformly random pseudo-random key extracted from the (possibly non-uniform) IKM. info is a context string distinguishing the use (“client-write-key”, “server-write-key”, “iv”, etc.). T(i) are intermediate L-byte chunks. OKM is the output keying material of arbitrary requested length, formed by truncating the concatenation of T(i) chunks.
The two-step design — extract random keying material from a possibly weak source, then expand into as many context-separated keys as needed — is the right abstraction for nearly all key derivation. In TLS 1.3, an HKDF-Expand-Label wrapper standardizes the info field; the entire TLS 1.3 key schedule (early secret → handshake secret → master secret → traffic keys) is HKDF-Extract and HKDF-Expand nested several levels deep. This means the security of every modern TLS 1.3 connection ultimately rests on HMAC-SHA-256.
The same primitive is appropriate at the application level: deriving per-tenant signing keys from a master, per-purpose keys from a single root secret, key versions from a master. The pattern subkey = HKDF(master, context_label) is the standard answer to “should I reuse this key for two purposes?” — instead, derive two sub-keys.
5.7 SCRAM: Salted Challenge Response
SCRAM (RFC 5802, RFC 7677) is the Salted Challenge Response Authentication Mechanism — the password-authentication method used by Kafka (SASL/SCRAM-SHA-256, SCRAM-SHA-512), PostgreSQL since 10, MongoDB since 3.0, and XMPP. It avoids transmitting the password and avoids the server having a copy of the plaintext password, using HMAC at every stage.
The core construction: from a password P and per-user salt s, the server stores StoredKey = H(HMAC(SaltedPassword, "Client Key")) where SaltedPassword = PBKDF2(P, s, iter_count). Authentication proceeds by exchanging nonces, with the client proving knowledge of SaltedPassword via a series of HMAC computations whose result the server can verify against StoredKey without ever holding the plaintext password. SCRAM gets the property “compromise of the database does not reveal passwords” — a meaningful upgrade over MD5(password) schemes that dominated databases in the 2000s.
5.8 SSH Host Key Fingerprints and MAC
OpenSSH uses HMAC in two distinct ways:
- Per-packet MAC in the SSH transport layer: each encrypted packet is authenticated with HMAC-SHA-256 or HMAC-SHA-512 (default for
mac=hmac-sha2-256-etm@openssh.com, etm = Encrypt-then-MAC), authenticating the ciphertext to defeat in-network tampering. - Known-hosts fingerprint hashing: when
HashKnownHosts yes, the~/.ssh/known_hostsfile storesHMAC-SHA-1(random_salt, hostname)instead of plaintext hostnames, so an attacker who reads the file cannot enumerate the user’s previously visited hosts. The random salt makes each entry unique even for identical hostnames.
5.9 Other Notable Deployments
- OAuth 1.0 (RFC 5849) defined
signature_method=HMAC-SHA1: client signs the request URL, method, and sorted parameters with the client secret. Obsolete since OAuth 2.0 moved to bearer tokens (2012), but historically important — and still encountered when integrating with legacy APIs (Twitter API v1.1, Flickr, some Atlassian endpoints). - IPsec ESP/AH (RFC 4868) uses HMAC-SHA-256-128 (HMAC-SHA-256 truncated to 128 bits) as the default authenticator for IPsec packets.
- Linux dm-verity / fs-verity: use HMAC over Merkle-tree roots to bind filesystem integrity to a per-image key.
- Git objects (LFS, signed commits via SSH keys): Git LFS uses HMAC for upload-URL signing; signed commits via SSH (introduced in OpenSSH 8.0) use the SSH MAC algorithms.
- Cookie signing in web frameworks: Rails (
config.secret_key_base), Django (SECRET_KEYviasigning.dumps), Flask (SECRET_KEYviaitsdangerous) all sign session cookies with HMAC so the server can detect tampering when the cookie is sent back. The client sees the plaintext payload but cannot mutate it without invalidating the tag. - Password reset / email-confirmation tokens: the typical pattern is
token = HMAC(server_secret, user_id || expiry || nonce), sent in a URL. The server validates by recomputing HMAC and constant-time comparing. This avoids storing pending-reset state in the database.
6. Pitfalls
6.1 Timing-Attack via Naive Comparison
The most common HMAC bug in production code: comparing the received tag against the computed tag with == or bytes.Equal. These early-return on the first mismatched byte. An attacker measuring the response time can learn how many leading bytes of their guess matched: if 4 bytes match, the loop runs 4 iterations before returning false; if 5 match, 5 iterations. The timing difference is on the order of nanoseconds per byte but observable over many trials, especially in scripted environments without network noise (process-to-process IPC, intra-cloud calls).
The result: the attacker can iteratively learn the correct tag byte by byte, mounting a forgery in roughly 256 × tag_length requests on average — feasible for a 32-byte tag.
The fix is constant-time comparison: a function that always inspects all bytes regardless of where the first mismatch occurs.
In Python:
import hmac
hmac.compare_digest(received_tag, expected_tag) # constant timeIn Go:
import "crypto/hmac"
if !hmac.Equal(receivedTag, expectedTag) {
// reject
}In Node.js:
const crypto = require('crypto');
if (!crypto.timingSafeEqual(receivedBuffer, expectedBuffer)) { /* reject */ }This is the canonical bug — every webhook integration tutorial that says “just compare with ==” is creating a CVE.
Worked example of the attack. Suppose the server validates a 32-byte tag with naive byte-by-byte comparison. The attacker controls the request body and the candidate tag bytes. They iterate:
- Send a request with tag
00 00 00 ... 00(32 zero bytes). The server’s comparison loop detects a mismatch on byte 0 (probably) and returns false after one byte of work. Measure the response time, call itt_0. - Send a request with tag
01 00 00 ... 00, then02 00 00 ... 00, …,FF 00 00 ... 00. For 255 of these 256 candidates, the server fails on byte 0. For the correct first byte, the server reads byte 0 successfully, then fails on byte 1 — taking measurably longer. - Identify the slowest response: that candidate’s first byte matches the expected tag.
- Fix byte 0, iterate byte 1 from
0x00to0xFF. Identify the slowest. That’s byte 1. - Continue for all 32 bytes.
The total cost: 256 × 32 = 8,192 requests in the ideal-conditions case to recover the entire tag for a single message. To average out noise, the attacker typically sends each candidate hundreds of times — so realistically 100,000 to 1,000,000 requests per tag. With network-jitter dominated environments (cross-WAN), the timing signal may be drowned out and the attack infeasible from the public Internet. But within a single datacenter, between containers on the same host, or via IPC, the signal is detectable. Daniel Coda Hale’s classic 2009 essay A Lesson in Timing Attacks demonstrated this against Java’s MessageDigest.isEqual (which until 2013 was not constant-time) and recovered 16-byte tags in roughly 30,000 requests on localhost.
Real CVEs from this exact bug:
- CVE-2014-9034 — WordPress 3.x used PHP’s
==(with type juggling, even worse —==returns true comparing certain hex strings to integers!) to validate password-reset cookies. A timing attack combined with type-juggling allowed full account takeover. Fixed by adoptinghash_equals()which PHP 5.6 added specifically for constant-time comparison. - CVE-2013-2099 — Python
ssl.match_hostname(used to verify TLS server hostnames) had a non-constant-time comparison that could leak hostname-validation behavior. Fixed in Python 3.4 by introducinghmac.compare_digest. - CVE-2016-9243 — LemonLDAP-NG (an enterprise SSO product) compared session tokens with
eqin Perl, enabling timing-based forgery against the SSO portal. - CVE-2018-1000620 —
node-cryptiles, a popular Node.js library, had afixedTimeComparisonfunction that itself was not constant-time due to early return on length mismatch combined with logic errors. The library was deprecated. - Many internal CVE-less reports: pre-2015, dozens of webhook integrations across SaaS vendors shipped naive comparisons; most have been patched but the pattern recurs whenever a new developer writes “let me roll my own webhook verifier.”
The standard-library answers across languages:
| Language | Constant-time function |
|---|---|
| Python (3.3+) | hmac.compare_digest(a, b) |
| Go | hmac.Equal(a, b) or subtle.ConstantTimeCompare(a, b) |
| Node.js (6+) | crypto.timingSafeEqual(a, b) (Buffer inputs of equal length) |
| Java (1.6.0_17+) | MessageDigest.isEqual(a, b) — but only became constant-time after the 2013 fix; older JVMs are vulnerable |
| Ruby | OpenSSL.fixed_length_secure_compare(a, b) (Ruby 2.5+); previously Rack::Utils.secure_compare |
| PHP (5.6+) | hash_equals($a, $b) |
| Rust | subtle::ConstantTimeEq trait, or ring::constant_time::verify_slices_are_equal |
| C# / .NET (4.6.2+) | CryptographicOperations.FixedTimeEquals(a, b) |
The implementation is invariably simple: XOR each byte of a against the corresponding byte of b, OR all the results into an accumulator, return accumulator == 0. The loop always runs n iterations regardless of where bytes differ. The standard caveats: inputs must be the same length (or length comparison itself becomes a side channel), and the comparison itself must be written in a way the compiler will not optimize into a short-circuiting loop (which is why standard libraries handwrite it in assembly or use compiler-fence intrinsics).
Practical attacker assumptions and realism. How realistic is a remote timing attack? Network jitter on the public Internet is typically 1–10 ms; the nanosecond-scale signal of a single-byte compare difference is buried under this noise. But:
- Within a single datacenter, RTT is sub-millisecond and jitter can be tens of microseconds — still much larger than a single byte compare, but the attacker can average over millions of requests.
- Co-located containers / serverless functions sharing a host: timing precision approaches CPU cycle counter resolution. The 2018 Meltdown/Spectre class of attacks demonstrated that side-channel timing measurements at nanosecond resolution are practical from co-resident attackers.
- Memcached, Redis, and other internal protocols: many run within trust boundaries with no network jitter, where naive comparisons leak fully.
The defensive posture is uniform: always use constant-time comparison for any HMAC verification, regardless of perceived attacker capability. The cost is one helper-function call; the upside is immunity to a class of attacks that has produced CVEs across nearly every language ecosystem.
6.2 Weak Keys
A 4-byte HMAC key is brute-forceable: 2^32 = ~4 billion is hours-to-days of GPU time. A 16-byte (128-bit) key is fine; a 32-byte (256-bit) key is the matched length for HMAC-SHA-256 and the recommended minimum.
Common bad patterns:
secret = "mysecret"(8 bytes, low entropy — guessable from a wordlist).secret = uuid.uuid4().hex(32 hex chars = 128 bits of entropy — acceptable).secret = secrets.token_bytes(32)(Python’s CSPRNG, 256 bits — recommended).
6.3 Reusing an HMAC Key Across Protocols (Confusing-Deputy)
If the same secret is used to HMAC two different message formats — say, webhook payloads in one protocol and JWT access tokens in another — an attacker who can submit content to one channel can forge MACs for the other. Concretely: suppose secret_K is used to sign both webhook bodies (raw JSON) and JWTs (header.payload). An attacker who controls webhook content (perhaps by submitting events via your own product’s API) can craft a webhook payload whose body is valid_jwt_header.malicious_payload. The HMAC the server emits for the webhook is also a valid HMAC for a JWT, because the underlying primitive doesn’t distinguish what role the bytes play. The attacker exfiltrates the webhook’s HMAC tag and reuses it as a JWT signature.
This is a textbook confusing-deputy problem: the HMAC primitive (the deputy) holds authority (the secret), and two callers with different intents can trick the deputy into authenticating bytes the wrong caller chose. The fix is key separation: derive a distinct sub-key per use:
subkey_webhook = HKDF-Expand(master, "v1.webhook-signing", 32)
subkey_jwt = HKDF-Expand(master, "v1.jwt-hs256", 32)
subkey_session = HKDF-Expand(master, "v1.session-cookie", 32)
Each protocol uses only its sub-key. Compromise of one sub-key does not affect others. The literal-string info argument acts as a domain separator: a malicious caller who controls webhook-signing content cannot produce signatures valid under the jwt-hs256 sub-key, because they don’t have the sub-key — only the master, which they don’t have either.
A weaker but still defensive alternative when HKDF isn’t available: prefix-pad the signing input with a protocol identifier. Stripe does this implicitly via its t=<ts>.<body> signing-payload format; an attacker who controls body cannot produce a string equivalent to a different protocol’s signing input, because the t=<ts>. prefix is fixed. But explicit sub-key derivation is the robust answer.
6.4 Truncating the Tag
Some protocols truncate HMAC-SHA-256 to 16 bytes (128 bits) for size. This is acceptable per RFC 4868 (which standardizes HMAC for IPsec). Truncating below 64 bits is dangerous: an online-oracle brute force becomes feasible at internet scale (2^64 ≈ 18 quintillion is impossible offline but if the verifier accepts arbitrary tag attempts and the attacker has a fast oracle, partial-tag forgeries could matter).
Best practice: never truncate below 128 bits.
6.5 Replay Without a Nonce or Timestamp
HMAC proves the sender knew the key, not that the message is fresh. Without a timestamp or nonce in the signed input, an attacker who captures a valid (message, tag) pair can replay it indefinitely. Stripe addresses this by including the timestamp t=... in the HMAC input and rejecting tags older than 5 minutes; AWS SigV4 includes X-Amz-Date and a service-specific request expiry. Always sign timestamp + body, not just body.
6.6 Logging the Secret
Webhook signature verification often happens in code that logs request details for debugging. If secret is interpolated into a log line, it leaks. Defense: never include the HMAC key in any log; redact at logging-config level; rotate immediately if a leak is detected.
6.7 Hash Algorithm Choice and SHA-1 Deprecation
HMAC-SHA-1 is still cryptographically secure as a MAC (the BCK proof does not require collision resistance), but SHA-1 is deprecated globally for collision-based attacks on signatures. For new deployments use HMAC-SHA-256 or HMAC-SHA-3-256. Migrate existing HMAC-SHA-1 deployments when convenient — they are not urgent CVEs but they are growing operationally awkward (FIPS-mode systems reject SHA-1).
6.8 Algorithm Confusion: Pin the Hash
A related and more dangerous pitfall: the verifier accepts whatever algorithm the sender declares in the message metadata, rather than enforcing an expected algorithm. This is the famous JWT alg=none vulnerability that hit many libraries in 2015 (CVE-2015-9235, auth0 disclosure): the attacker submits a token with {"alg":"none"} in the header and no signature, and the verifier — trusting the header — skips verification and accepts the token.
A subtler variant is HS256/RS256 confusion: the server is configured with an RSA public key for RS256 verification. The attacker takes that public key (it’s public), submits a token with {"alg":"HS256"}, and signs the payload using the public key bytes as if they were an HMAC secret. A naïve library that accepts whatever alg the header specifies will call HMAC-SHA-256(public_key_bytes, payload) for verification — and successfully verify, because the attacker computed the same thing. The mitigation: the verifier must pin the algorithm at configuration time, ignoring the alg header, or at minimum maintain an allowlist that does not mix symmetric and asymmetric algorithms for the same key.
The general principle: HMAC verification logic must hardcode both the hash function and the exact construction. Never accept algorithm as a parameter from untrusted input. If you support multiple algorithms, route based on out-of-band metadata (a key ID that determines algorithm), not on a self-declared header field.
6.9 Signature Coverage: What’s Actually Signed
A pitfall less famous than timing attacks but no less common: signing only part of the request, leaving the rest tamperable. A naive webhook signer might compute HMAC(secret, body) — only the request body is authenticated. An attacker who controls the request URL, query parameters, or headers can modify those without invalidating the signature:
- The Content-Type header is changed from
application/jsontoapplication/x-www-form-urlencoded, causing the receiver to parse differently. - The URL path is rewritten from
/refund/cancelto/refund/approvewhile the body remains the same. - A
?force=truequery parameter is added.
The receiver verifies the body’s HMAC, sees a match, and dispatches to the attacker-controlled URL/handler.
The robust pattern is to sign every byte the receiver will use to make a decision. AWS SigV4 does this exhaustively: the canonical request includes method, URI path, query string, all signed headers, and body hash. Stripe partially defends against this by sending webhooks to a single endpoint URL (no path variation), but they sign body+timestamp only — header tampering would not be detected, which is fine because Stripe controls all the headers that matter.
For DIY webhook schemes, the explicit recommendation: sign at minimum method || '\n' || path || '\n' || sorted_headers || '\n' || body. The exact canonicalization scheme matters less than the property that every byte the verifier reads is covered.
7. Key Management and Rotation
HMAC’s security collapses entirely if the key is compromised — there is no “public” half of an HMAC key, so the secret is the entire security boundary. Treating HMAC keys with the discipline owed to private RSA keys is non-negotiable.
7.1 Key Length and Generation
The recommended minimum for HMAC-SHA-256 is 32 bytes (256 bits) of cryptographically random data. This matches the hash output L; recall that keys longer than the block size B (64 bytes for SHA-256) are first hashed to L bytes, so 32 bytes is also the maximum useful entropy. Going below 32 bytes weakens the construction proportionally — a 16-byte key gives 128-bit security, still infeasible to brute-force but the security margin shrinks.
Generation must use a cryptographically secure random source:
- Python:
secrets.token_bytes(32)— wraps the OS CSPRNG viaos.urandom. - Go:
crypto/rand.Read(key)wherekey := make([]byte, 32). - Node.js:
crypto.randomBytes(32). - Linux shell:
head -c 32 /dev/urandom | base64.
Wrong patterns: Math.random() (not cryptographic, broken in seconds); time.Now().UnixNano() (low entropy, easily guessed); md5(username + salt) (deterministic, predictable); environment variable SIGNING_KEY="changeme" (an actual production string seen in incident reports).
7.2 Key Derivation from a Master Secret
In multi-tenant systems or multi-purpose deployments, rather than provisioning N independent keys, derive each from a single high-entropy master:
master = secrets.token_bytes(32) # 256-bit master, stored once
tenant_key = HKDF-Expand(master, f"tenant:{tenant_id}".encode(), 32)
purpose_key = HKDF-Expand(master, b"v1.purpose-cookie-sign", 32)
This pattern reduces the secret-management surface to a single value (the master) while still providing per-tenant or per-purpose isolation. Compromise of one derived key reveals nothing about the master or other derived keys (HMAC is one-way). The master itself never appears in application code; it lives in the secrets backend and is loaded once at startup to derive what’s needed.
7.3 Where to Store HMAC Keys
In ascending order of operational maturity:
- Environment variables / config files committed to git — never acceptable for production. Even encrypted at rest, this puts the secret in every developer’s checkout, every CI log, every shell history.
- Environment variables loaded from a
.envfile at deploy time — acceptable for small deployments if.envis provisioned via a secure channel (sealed-secret, encrypted artifact). Common pitfall: the.envfile accidentally committed. - Secrets injected from a secret manager at process start: HashiCorp Vault, AWS Secrets Manager, Google Secret Manager, Azure Key Vault. The application authenticates to the secrets backend (typically via instance-attached identity), fetches the key into memory at startup, and never writes it to disk. This is the modern baseline for cloud-native deployments.
- Secrets remain inside an HSM (Hardware Security Module) or KMS-style managed signer: the application never holds the key bytes; it calls a “compute HMAC” API exposed by the HSM, which performs the operation inside hardware and returns the tag. AWS KMS, Google Cloud KMS, and Azure Key Vault all offer this for HMAC keys. The HSM enforces audit logging, rate limiting, and IAM-based access control. The cost is latency (a round-trip per HMAC) and dollars; the benefit is that even root on the application host cannot exfiltrate the key.
For most production webhook-verification or token-signing workloads, tier 3 is the right answer. Tier 4 is appropriate where regulatory requirements demand non-exportable keys (PCI-DSS for payment systems, certain regulated financial workflows).
7.4 The Two-Window Rotation Pattern
Rotating an HMAC secret without downtime requires accepting both old and new for an overlap window:
- Phase 0 (steady state): only
secret_v1is active. Both ends use it. - Phase 1 (introduce new key): distribute
secret_v2. The verifier accepts a tag if it validates under eitherv1orv2. The signer continues usingv1. - Phase 2 (cut over signers): signer switches to
v2. Verifier still accepts both. - Phase 3 (drain old): after enough time that no
v1-signed messages are in flight, verifier removesv1. - Phase 4 (steady state): only
secret_v2is active.
The verifier’s “accept either” logic should explicitly key-version-tag the messages where possible: include a kid in the JWT header, or a version=v2 field in the webhook payload, so verifiers don’t have to try both keys (which doubles work and leaks timing information about which key matched).
Length of the overlap window depends on the longest-lived signed artifact: for short-lived (5-minute) webhooks, an overlap of 30 minutes is plenty; for week-long-lived JWT refresh tokens, the overlap needs to be the token lifetime plus margin. The general rule: overlap >= max_token_lifetime + clock_skew_budget.
7.5 Rotation Triggers
Rotate proactively on a schedule (typically every 90 days for high-value keys, annually for lower-stakes) and reactively on these triggers:
- A team member with key access leaves the company.
- A laptop with key access is lost or stolen.
- Logs or backups suspected to contain the key are exposed.
- The key was used to sign a value that turned out to be malicious (e.g., a leaked JWT used for fraud — invalidate by rotating).
- Anomalous verification volume detected (potential brute-force in progress).
The rotation procedure should be drilled — practice rotating the staging-environment key quarterly so the production rotation is muscle memory, not a panic operation.
8. Worked Example: Stripe Webhook Verification
A Stripe webhook arrives at POST /stripe/webhook with:
- Header
Stripe-Signature: t=1731000000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd. - Body: the raw request bytes (a JSON event).
The signing string Stripe constructs on its side is <timestamp>.<body>, signed with HMAC-SHA-256 using the webhook endpoint’s whsec_... secret (visible in the Stripe dashboard).
Python verification
import hmac
import hashlib
import time
def verify_stripe(secret: bytes, body: bytes, sig_header: str, tolerance: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in sig_header.split(","))
timestamp = int(parts["t"])
received = bytes.fromhex(parts["v1"])
# Replay protection
if abs(time.time() - timestamp) > tolerance:
return False
signed_payload = f"{timestamp}.".encode() + body
expected = hmac.new(secret, signed_payload, hashlib.sha256).digest()
# CONSTANT-TIME comparison — never use ==
return hmac.compare_digest(received, expected)Walkthrough:
- We parse the
t=...,v1=...header into a dict. - We check the timestamp against the current time; if more than 5 minutes off, we reject — this prevents an attacker who captured an old request from replaying it.
- We construct the exact bytes Stripe signed:
b"1731000000." + body. - We compute the HMAC-SHA-256 over that with our shared secret.
- We compare in constant time. If we used
received == expected, the function would short-circuit on the first byte mismatch and leak timing.
Go verification
package webhook
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"strconv"
"strings"
"time"
)
func VerifyStripe(secret, body []byte, sigHeader string, tolerance time.Duration) error {
var ts int64
var receivedHex string
for _, kv := range strings.Split(sigHeader, ",") {
k, v, _ := strings.Cut(kv, "=")
switch k {
case "t":
ts, _ = strconv.ParseInt(v, 10, 64)
case "v1":
receivedHex = v
}
}
received, err := hex.DecodeString(receivedHex)
if err != nil {
return fmt.Errorf("bad sig hex: %w", err)
}
if d := time.Since(time.Unix(ts, 0)); d > tolerance || d < -tolerance {
return fmt.Errorf("timestamp outside tolerance")
}
mac := hmac.New(sha256.New, secret)
fmt.Fprintf(mac, "%d.", ts)
mac.Write(body)
expected := mac.Sum(nil)
if !hmac.Equal(received, expected) { // constant time
return fmt.Errorf("signature mismatch")
}
return nil
}Walkthrough:
hmac.New(sha256.New, secret)initializes HMAC-SHA-256 with the secret.Fprintf(mac, "%d.", ts)thenmac.Write(body)builds the signed payload incrementally — equivalent to a singlemac.Write(big_buf)but avoids the allocation.mac.Sum(nil)finalizes and returns the tag bytes.hmac.Equalis the standard library’s constant-time comparator.
Both implementations reach the identical 32-byte tag if the secret matches and the body is byte-for-byte identical to what Stripe signed. The most common bug in production code is body mutation: a logging middleware that re-serializes JSON (changing whitespace, key order, or floating-point representation) breaks signature verification. The receiver must reach the raw bytes before any parser touches them.
9. The Security Argument Recap
Why HMAC works, in compressed form:
- The inner hash
H(K' ⊕ ipad || m)produces a “keyed digest” — its output depends onK'in a way that prevents an attacker withoutK'from inferring it. - The outer hash
H(K' ⊕ opad || inner_digest)re-keys with a different derived key, ensuring length-extension on the inner hash cannot extend the outer. - The inner and outer derived keys (
K' ⊕ ipadandK' ⊕ opad) are statistically independent under any reasonable model of the attacker, because XOR with constants of high Hamming distance produces bit-decorrelated values. - The whole construction reduces to: HMAC is a PRF if the compression function of
His a PRF.
10. HMAC vs Other MAC Constructions and AEAD
HMAC is one of several primitives that produce a Message Authentication Code or provide message authenticity. Understanding when each is appropriate is part of cryptographic literacy.
10.1 CMAC (Cipher-based MAC)
CMAC, standardized in NIST SP 800-38B, uses a block cipher (typically AES-128 or AES-256) instead of a hash function. The construction processes the message in cipher-block-size chunks via CBC-MAC with a final-block tweak to defeat length-extension. CMAC’s security reduces to the underlying block cipher being a pseudo-random permutation.
When CMAC is preferred over HMAC: embedded systems with hardware AES (most modern CPUs and many microcontrollers via AES instructions) but no hardware hash acceleration. In those environments AES-CMAC is faster than HMAC-SHA-256. Used in IEEE 802.11i (WPA2), IPsec (RFC 4493), and some EMV (chip-card) protocols. For general software services, HMAC remains preferred because hash hardware acceleration (SHA-NI on Intel, ARMv8 Crypto Extensions) is similarly widespread and the SHA-2 family’s library support is universal.
10.2 GMAC and AES-GCM
GMAC is the MAC half of AES-GCM (Galois/Counter Mode), an Authenticated Encryption with Associated Data (AEAD) cipher specified in NIST SP 800-38D. GCM combines AES in counter mode (for confidentiality) with multiplication in the Galois field GF(2^128) (for authentication). The MAC component alone — GMAC — can be used to authenticate without encryption, but in practice GCM (encrypt + authenticate together) is used almost exclusively.
GCM is extremely fast on hardware with AES-NI and PCLMULQDQ (carryless multiply) instructions, hitting 5+ GB/s per core. TLS 1.3 mandates GCM-mode cipher suites (TLS_AES_128_GCM_SHA256, TLS_AES_256_GCM_SHA384) as the default. The catch: GCM is brittle under nonce reuse — repeating an IV with the same key catastrophically breaks both confidentiality and authentication, allowing the attacker to recover the authentication key. Implementations must guarantee IV uniqueness, typically via a counter.
10.3 Poly1305 and ChaCha20-Poly1305
Poly1305, designed by Daniel J. Bernstein, is a one-time MAC based on polynomial evaluation over the prime field GF(2^130 - 5). Used in ChaCha20-Poly1305 (RFC 8439) — the alternative AEAD construction to AES-GCM. ChaCha20-Poly1305 has two advantages over AES-GCM:
- No hardware AES requirement: ChaCha20 runs at competitive speed on any modern CPU using only 32-bit add/rotate/XOR instructions. This makes it the default AEAD for mobile clients (ARM cores without AES, until ARMv8 added it) and was Google’s choice for Android-to-server traffic in 2014.
- Side-channel resistance: AES table-based implementations had cache-timing leaks (Bernstein 2005, others); ChaCha20 is bitwise-only with no table lookups, immune to cache-timing.
Like GCM, Poly1305 is nonce-misuse-fragile. TLS 1.3 supports TLS_CHACHA20_POLY1305_SHA256 as a peer alongside GCM cipher suites; clients without AES hardware (older mobile, low-power IoT) typically negotiate ChaCha20-Poly1305.
10.4 KMAC (SHA-3-based MAC)
KMAC, specified in NIST SP 800-185, is built on Keccak (the algorithm underlying SHA-3). Because Keccak uses a sponge construction rather than Merkle–Damgård, it is not vulnerable to length-extension — meaning KMAC does not need the double-hash structure HMAC requires. KMAC’s construction is essentially Keccak(key || message || padding), much simpler and slightly faster than HMAC-SHA-3.
KMAC has not seen widespread adoption despite its technical elegance, because (a) SHA-3 itself adopted slowly relative to SHA-2 — Intel SHA Extensions only accelerate SHA-1 and SHA-2, not SHA-3, so KMAC has no hardware speedup on common CPUs; (b) HMAC works fine; and (c) library support is uneven (Python added SHA-3 in 3.6, but hmac module’s compare_digest and new() are still HMAC-only). For new code with no constraints, HMAC-SHA-256 remains the path of least resistance.
10.5 HMAC vs AEAD: When Each Applies
This is the most consequential design decision. Both HMAC and AEAD (Authenticated Encryption with Associated Data) provide message integrity, but they differ in what they protect:
- HMAC alone authenticates a message without encrypting it. The message remains plaintext on the wire. Use when the body is non-sensitive (a webhook payload that’s already going to a specific authenticated receiver), the verifier needs to read the body (a JWT whose
payloadis intentionally readable), or when TLS already provides confidentiality and you need application-level integrity (a webhook surviving a TLS-terminating proxy). - AEAD (AES-GCM, ChaCha20-Poly1305, AES-SIV) encrypts and authenticates in one operation. The body is opaque to anyone without the key. Use for any data that should remain confidential to non-verifiers — e.g., session cookies whose content the client should not be able to read, or message contents in end-to-end protocols.
A common bug is using HMAC to “sign” a sensitive payload while leaving it plaintext — solving integrity but accidentally exposing data. Conversely, encrypting without authentication (raw AES-CTR or CBC without a MAC) is never correct: padding-oracle and bit-flip attacks have repeatedly broken such schemes (CBC: BEAST 2011, Lucky Thirteen 2013, POODLE 2014). The rule is unencrypted-MAC for non-confidential data, AEAD for confidential data, never encryption without authentication.
For software services, HMAC-SHA-256 remains the default for message-authentication scenarios because every language’s standard library has it, every protocol mentioned above uses it, and there is no operational reason to switch. Reach for AEAD (specifically AES-GCM or ChaCha20-Poly1305) when the data needs to be both encrypted and authenticated.
11. See Also
- JSON Web Tokens — uses HMAC for the
HS256algorithm. - Webhook Delivery System Design — uses HMAC to sign payloads delivered to subscribers.
- Mutual TLS — uses HMAC inside the TLS record layer for some ciphersuites.
- OAuth 2.0 — OAuth 1.0 used HMAC-SHA-1 for request signing; OAuth 2.0 moved to bearer tokens, but JWT access tokens often use HMAC.
- OpenID Connect — ID Token verification with
HS256is HMAC. - Personally Identifiable Information — HMAC is used for pseudonymization (e.g., HMAC of email as a stable but non-reversible identifier).
- API Gateway System Design — gateways validate HMAC-signed tokens and webhook payloads.
- SWE Interview Preparation MOC
- Authentication MOC
- Major System Designs MOC