HMAC Internals — How HMAC Works, Key Management, and When to Use It Over Plain Hashing
Every API request to Stripe, every JWT token signed with HS256, every webhook callback from GitHub — they all rely on HMAC (Hash-based Message Authentication Code). HMAC adds a secret key to a hash function, producing a code that proves the message was created by someone who knows the key and has not been modified. This guide explains the algorithm internals (RFC 2104), key management best practices, and practical implementation patterns.
1. What HMAC Solves That Hashing Doesn't
A plain cryptographic hash like SHA-256 guarantees integrity — the same input always produces the same output. But anyone can compute the hash. If you send message + SHA-256(message) to a server, an attacker who modifies the message can also recompute the hash. You need something stronger.
HMAC adds authentication on top of integrity. It requires a secret key that only the sender and receiver share:
| Property | Plain Hash | HMAC |
|---|---|---|
| Integrity (detect modification) | Yes | Yes |
| Authentication (prove who sent it) | No — anyone can hash | Yes — requires secret key |
| Keyless | Yes | No — requires shared key |
| Use case | File checksums, content addressing | API signatures, JWT, webhook verification |
2. How the HMAC Algorithm Works (RFC 2104)
HMAC was defined by H. Krawczyk, M. Bellare, and R. Canetti in RFC 2104 (1997). The construction is elegantly simple — it hashes the message twice, each time mixing in the key via XOR:
HMAC(K, m) = H((K' ⊕ opad) ∥ H((K' ⊕ ipad) ∥ m))
Step-by-Step Construction
- Prepare the key (
K'): If the key is shorter than the hash block sizeB(64 bytes for MD5/SHA-1/SHA-256, 128 bytes for SHA-384/SHA-512), pad with zeros on the right. If longer, hash it first:K' = H(K)padded toBbytes. - Inner hash: XOR
K'with the ipad (0x36 repeated B times), concatenate the message, and hash:H((K' ⊕ ipad) ∥ m). - Outer hash: XOR
K'with the opad (0x5C repeated B times), concatenate the inner hash result, and hash again:H((K' ⊕ opad) ∥ inner_hash).
Why Two Hashes?
The double-hash construction prevents length extension attacks. With a plain H(key ∥ message), an attacker who knows the hash output can compute H(key ∥ message ∥ attacker_data) without knowing the key, because Merkle-Damgard hash functions like SHA-256 allow the hash state to be resumed. HMAC's outer hash makes this attack impossible — the attacker would need to invert the outer hash to extend it.
3. HMAC-SHA256 vs HMAC-MD5 vs HMAC-SHA1 — Security Comparison
| Algorithm | Hash | Output Length | Block Size | Security Status | Recommendation |
|---|---|---|---|---|---|
| HMAC-MD5 | MD5 | 128 bits | 64 B | Not broken in HMAC mode (RFC 6151) | Deprecated — avoid in new systems |
| HMAC-SHA1 | SHA-1 | 160 bits | 64 B | Not broken in HMAC mode | Legacy only — OAuth 1.0a still uses it |
| HMAC-SHA256 | SHA-256 | 256 bits | 64 B | Secure — FIPS 180-4 | Recommended default |
| HMAC-SHA384 | SHA-384 | 384 bits | 128 B | Secure — FIPS 180-4 | High-security applications |
| HMAC-SHA512 | SHA-512 | 512 bits | 128 B | Secure — FIPS 180-4 | Maximum security margin |
4. Key Length and Key Management
How Long Should an HMAC Key Be?
| HMAC Variant | Recommended Key Length | Minimum | Rationale |
|---|---|---|---|
| HMAC-SHA256 | 256 bits (32 bytes) | 128 bits | Keys shorter than hash output reduce security; longer keys are hashed first (no extra benefit) |
| HMAC-SHA384 | 384 bits (48 bytes) | 192 bits | Match hash output length |
| HMAC-SHA512 | 512 bits (64 bytes) | 256 bits | Match hash output length |
Key Management Rules
- Generate keys with a CSPRNG — never use passwords, timestamps, or predictable values as keys.
- One key per service — compromising a webhook key should not expose your JWT signing key.
- Rotate keys periodically — most API providers allow multiple active keys during rotation.
- Store keys securely — use environment variables or a secrets manager (Vault, AWS KMS), never hardcode in source or commit to git.
- Transmit keys out-of-band — webhook secrets should be provisioned via a dashboard, not sent over email or chat.
5. Practical Use Cases with Code Examples
5.1 API Request Signing (Stripe-Style)
Stripe, GitHub, and Slack all sign webhook payloads with HMAC-SHA256. The pattern is the same:
// Server-side verification (Node.js)
const crypto = require('crypto');
function verifyWebhook(payload, signatureHeader, secret) {
const expected = crypto
.createHmac('sha256', secret)
.update(payload, 'utf8')
.digest('hex');
// CRITICAL: Use timing-safe comparison!
const expectedBuf = Buffer.from(expected, 'hex');
const actualBuf = Buffer.from(signatureHeader, 'hex');
return crypto.timingSafeEqual(expectedBuf, actualBuf);
}
// Usage
const sig = req.headers['x-signature-sha256'];
if (!verifyWebhook(req.rawBody, sig, process.env.WEBHOOK_SECRET)) {
return res.status(401).send('Invalid signature');
}
=== to compare HMACs: String equality comparison is vulnerable to timing attacks — an attacker can determine the correct HMAC character by character by measuring response time. Always use crypto.timingSafeEqual() (Node.js), hmac.compare_digest() (Python), or hash_equals() (PHP).5.2 JWT HS256 Token Verification
A JWT signed with HS256 is simply Base64url(header) + "." + Base64url(payload) authenticated with HMAC-SHA256:
# Python — verify a JWT HS256 token
import hmac
import base64
def verify_jwt_hs256(token, secret):
# Split token into header.payload and signature
parts = token.split('.')
if len(parts) != 3:
return False
signing_input = f"{parts[0]}.{parts[1]}".encode('utf-8')
signature = base64.urlsafe_b64decode(parts[2] + '==') # fix padding
expected = hmac.new(
secret.encode('utf-8'),
signing_input,
'sha256'
).digest()
return hmac.compare_digest(signature, expected)
5.3 AWS Signature Version 4
AWS Sig V4 is a multi-round HMAC derivation chain that produces a final signing key and signature:
# AWS Sig V4 key derivation
import hmac, hashlib
def get_aws_sigv4_key(secret_key, date, region, service):
k_date = hmac.new(
('AWS4' + secret_key).encode('utf-8'),
date.encode('utf-8'),
hashlib.sha256
).digest()
k_region = hmac.new(k_date, region.encode('utf-8'), hashlib.sha256).digest()
k_service = hmac.new(k_region, service.encode('utf-8'), hashlib.sha256).digest()
k_signing = hmac.new(k_service, b'aws4_request', hashlib.sha256).digest()
return k_signing # Used to sign the actual request
5.4 TOTP (Time-Based One-Time Password)
TOTP (RFC 6238) is HMAC-SHA1 applied to a time-based counter, then truncated to 6 digits:
# TOTP generation
import hmac, hashlib, struct, time
def totp(secret_base32, digits=6, period=30):
import base64
key = base64.b32decode(secret_base32)
counter = int(time.time()) // period
msg = struct.pack('>Q', counter) # 8-byte big-endian
h = hmac.new(key, msg, hashlib.sha1).digest()
offset = h[-1] & 0x0F
code = struct.unpack('>I', h[offset:offset+4])[0] & 0x7FFFFFFF
return str(code % (10 ** digits)).zfill(digits)
6. HMAC vs CMAC vs HKDF
| Function | Based On | Purpose | Standard |
|---|---|---|---|
| HMAC | Hash function | Message authentication | RFC 2104 / FIPS 198-1 |
| CMAC | Block cipher (AES) | Message authentication (cipher-based) | NIST SP 800-38B |
| HKDF | HMAC | Key derivation from a master secret | RFC 5869 |
| KMAC | Keccak/SHA-3 | Message authentication (SHA-3 family) | NIST SP 800-185 |
7. Common Bugs and Debugging Checklist
| Symptom | Cause | Fix |
|---|---|---|
| HMAC mismatch on server | Key encoding differs (UTF-8 vs hex vs Base64) | Verify key format — decode hex/Base64 keys before using |
| HMAC works locally but fails in production | Message includes trailing newline or BOM | Strip \n and \r\n from both sides; use raw request body |
| Webhook verification always fails | JSON body was parsed and re-serialized (changed whitespace) | Use the raw request body, not JSON.stringify() |
| HMAC-SHA256 produces different results | URL encoding vs raw bytes in message | Use the exact same byte sequence on both sides; check for + vs %20 |
| Timing attack suspected | Using === instead of constant-time comparison | Replace with timingSafeEqual / compare_digest |
| AWS Sig V4 fails | Wrong date format or region string | Date must be YYYYMMDD; region must match the endpoint |
8. RFC Test Vectors
RFC 4231 defines standard test vectors for HMAC-SHA2. Use these to verify your implementation:
# RFC 4231 Test Case 2
Key: "Jefe" (4 bytes)
Data: "what do ya want for nothing?" (28 bytes)
Results:
HMAC-SHA256: 5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843
HMAC-SHA384: af45d2e376484031617f78d2b58a6b1b9c7ef464f5a01b47e42ec3736322445e
8e2240ca5e69e2c78b3239ecfafb72e2
HMAC-SHA512: 164b7a7bfcf619e8148e915db6e1e4b0fa0e4e5f3a0a3f2d0e0f1e2d3c4b5a69
8e7f6e5d4c3b2a190887e6f5d4c3b2a1 (truncated for display)
Jefe and message what do ya want for nothing?, then compare the HMAC-SHA256 output against the test vector above.9. Quick Reference
| Scenario | Algorithm | Key Recommendation |
|---|---|---|
| New API / webhook signing | HMAC-SHA256 | 256-bit random key (32 bytes, hex-encoded) |
| JWT token signing | HMAC-SHA256 (HS256) | ≥256 bits; consider RS256/ES256 for multi-tenant |
| AWS API requests | HMAC-SHA256 (Sig V4) | AWS secret access key (40 chars Base64) |
| OAuth 1.0a | HMAC-SHA1 | Consumer secret + token secret |
| TOTP authenticator apps | HMAC-SHA1 (common) or HMAC-SHA256 | Base32-encoded shared secret |
| Legacy / compatibility | HMAC-MD5 | Acceptable if already deployed; migrate to SHA-256 |