Skip to content

HMAC: What It Is, How It Works, and When It Is the Wrong Tool

A grounded explainer for backend engineers: HMAC-SHA-256 for webhooks, signed URLs, and internal auth, with three-language code and the boundary where digital signatures take over.

Webhook receivers, signed download URLs, and internal service calls all share one question: did this request actually come from the party we share a secret with, and did it arrive intact? A constant-time string compare, raw-body hashing, and clock-skew handling have to be right at the same time, or the protection is decorative. For symmetric-trust integrity where both sides can hold the same secret, HMAC-SHA-256 with the standard library's constant-time compare is the right default; this post defines the primitive, shows runnable code in three languages, and names where HMAC stops being the right tool.

What HMAC actually is

HMAC stands for Hash-based Message Authentication Code. RFC 2104 defines it as H(K_opad, H(K_ipad, message)), where K_ipad is the key XOR-ed with the byte 0x36 repeated to the block size of the underlying hash, and K_opad is the key XOR-ed with 0x5C repeated the same way. The two-layer hash is what makes HMAC safe against length-extension attacks that break a naive H(secret || message) over Merkle-Damgard hashes like SHA-256. FIPS 198-1 is the formal standard, and HMAC-SHA-256 is the default instantiation today.

The verifier recomputes the MAC and compares it to the received tag. The compare must be constant-time, meaning it inspects every byte regardless of where the first mismatch occurs. A == or strcmp returns early on the first differing byte, which leaks the position of the mismatch through timing and lets an attacker recover the tag byte by byte over many requests. The standard library of every mainstream language exposes a constant-time compare; use it.

The construction itself is cheap. A single-digit microsecond cost on a kilobyte-sized payload is typical, so HMAC almost never shows up in a performance profile.

Three implementations

The same shape works in every language: hash the raw bytes the sender hashed, decode the header to bytes, then constant-time compare. Node's timingSafeEqual throws on length mismatch, so the Node example guards with an explicit length check before the compare. Python's compare_digest and Go's hmac.Equal already return false on unequal length, so a length check is redundant in those snippets and would only add noise.

typescript
// Node.js: verify a webhook signatureimport { createHmac, timingSafeEqual } from "node:crypto";
export function verify(  rawBody: Buffer,  headerSig: string,  secret: string,): boolean {  if (!/^[0-9a-f]{64}$/i.test(headerSig)) return false;  const expected = createHmac("sha256", secret).update(rawBody).digest();  const received = Buffer.from(headerSig, "hex");  if (expected.length !== received.length) return false;  return timingSafeEqual(expected, received);}

Buffer.from(str, "hex") does not throw on malformed input. It silently drops non-hex characters and the trailing nibble of any odd-length string, returning a shorter buffer than the caller might expect. The regex requires exactly 64 hex characters because HMAC-SHA-256 always produces a 32-byte tag, which closes the silent-truncation gap before decode. The length guard after decode is then redundant for SHA-256 but kept as defense in depth.

python
# Python: same idea, stdlib hmacimport hmacimport hashlib
def verify(raw_body: bytes, header_sig: str, secret: bytes) -> bool:    expected = hmac.new(secret, raw_body, hashlib.sha256).digest()    try:        received = bytes.fromhex(header_sig)    except ValueError:        return False    return hmac.compare_digest(expected, received)
go
// Go: crypto/hmac.Equal is constant-timepackage webhook
import (    "crypto/hmac"    "crypto/sha256"    "encoding/hex")
func Verify(rawBody []byte, headerSig string, secret []byte) bool {    mac := hmac.New(sha256.New, secret)    mac.Write(rawBody)    expected := mac.Sum(nil)    received, err := hex.DecodeString(headerSig)    if err != nil {        return false    }    return hmac.Equal(expected, received)}

Three details carry the security across all three. First, the input is the raw byte stream of the body, not a JSON object that has been parsed and re-stringified. Second, the comparison uses the stdlib primitive (timingSafeEqual, compare_digest, hmac.Equal), never the language's equality operator. Third, the secret is treated as opaque bytes; passing a hex-encoded string and a binary digest to the same compare is a length-mismatch bug waiting to fire.

When HMAC is the right tool

The pattern fits any case where both sides legitimately share a secret and the goal is integrity plus authenticity.

Webhook receivers. Stripe sends Stripe-Signature: t=<unix-ts>,v1=<hex> where v1 is HMAC-SHA-256 over the string t.raw-body. The timestamp is part of the signed material, which lets the receiver reject anything outside a five-minute window and stops naive replay. GitHub takes a similar shape with X-Hub-Signature-256. Shopify signs the raw body and sends the tag in X-Shopify-Hmac-Sha256, base64-encoded rather than hex. If the Node verifier above is reused on a Shopify endpoint, swap Buffer.from(headerSig, "hex") for Buffer.from(headerSig, "base64") and drop the hex regex.

Signed URLs. A short-lived download or upload URL embeds parameters (expiry, resource, method) and an HMAC tag over them. The server recomputes the tag on each request. The reader needs no public-key infrastructure; the issuer and the verifier are the same party.

Internal service auth between trusted peers. Two services under one operator, sharing a secret rotated through a secret store, can sign request envelopes with HMAC. This is the prototype tier of internal auth before mTLS or a service mesh; it is not a permanent endpoint, but it is honest and small.

Request signing protocols. AWS Signature Version 4 derives a per-request signing key by chaining HMAC across the date, region, and service: kDate = HMAC("AWS4"+kSecret, YYYYMMDD), then kRegion, kService, and finally kSigning = HMAC(kService, "aws4_request"). The signature is HMAC(kSigning, stringToSign). The chain narrows the blast radius of any single derived key. The pattern is worth borrowing for internal signing infrastructure.

When HMAC is the wrong tool

The boundary is sharp once you name what HMAC cannot do.

Password storage. HMAC is fast on purpose. Password hashing must be slow on purpose, with cost parameters that scale with hardware. Use Argon2id or bcrypt. The OWASP Cryptographic Storage Cheat Sheet is explicit on this.

Asymmetric trust and non-repudiation. Anyone who can verify an HMAC can also forge one, because verification and signing use the same key. If many parties need to verify but only one party must be able to sign, or if a third party must later prove which side produced a message, the right tool is a digital signature. Ed25519 is the modern default; ECDSA P-256 and RSA-PSS are acceptable alternatives where Ed25519 is not supported. JWT with HS256 between two known services is fine; JWT with HS256 distributed to a verifier pool you do not fully trust is asymmetric trust in disguise, and the right move is RS256 or EdDSA.

Random tokens for sessions or password resets. A session ID is not derived from data; it is just an opaque random value the server stores. Use a CSPRNG (crypto.randomBytes, secrets.token_urlsafe, crypto/rand.Read) and store the value, salted and hashed if needed, in your session store. HMAC offers nothing here.

Confidentiality. HMAC does not encrypt. If the requirement is that an attacker cannot read the message, use an authenticated encryption mode like AES-GCM or ChaCha20-Poly1305, which provide both confidentiality and integrity.

Operational notes

Key rotation. Design the verifier to accept several active keys at once. On rotation, add the new key with the highest priority and keep the old key live for one full rollout window before retiring it. Producers cut over to the new key on their own schedule.

typescript
// Multi-key verifier shapeconst ACTIVE_KEYS: ReadonlyArray<{ id: string; secret: string }> = [  { id: "2026-05", secret: process.env.HMAC_KEY_CURRENT! },  { id: "2026-04", secret: process.env.HMAC_KEY_PREVIOUS! },];
export function verifyAny(rawBody: Buffer, headerSig: string): boolean {  for (const { secret } of ACTIVE_KEYS) {    if (verify(rawBody, headerSig, secret)) return true;  }  return false;}

The reject path traverses every key, so an attacker without a valid signature gets no timing signal about which keys are active. The accept path does return on first match, which lets a timing observer learn which key signed the request, but only someone who already holds a valid signature can run that observation. The leak is low impact in practice, and the simpler shape wins.

This try-all shape fits inbound third-party webhooks where you do not control the envelope. When you are the issuer of signed URLs or internal RPC, embed a key id and a signed timestamp in the message, look the key up by id, and enforce validFrom <= t <= validUntil. JWT's kid plus exp is the canonical worked pattern.

Key length. Use 32 random bytes for HMAC-SHA-256 (the block size of SHA-256 is 64 bytes, but the security level caps at the digest size). A 32-byte CSPRNG-generated secret avoids the historical key-length-normalization debate around RFC 2104 entirely.

Canonicalization. Do not sign over a JSON object you have parsed and re-serialized. Whitespace, key order, and Unicode escapes differ between encoders, and the tag fails to validate. Stripe and GitHub both sign the raw request body bytes for this reason. In Express, mount express.raw({ type: "application/json" }) on the webhook route only and parse JSON downstream. In Koa, capture the buffer in a middleware before the body parser. In Go and Python frameworks, read the body once into bytes and pass that buffer to both the verifier and the application handler.

Timestamp and replay. A valid signature does not prove freshness. Sign a timestamp inside the message (t.body in Stripe's pattern), reject anything outside a tight window (300 seconds is typical), and persist (provider, event_id) in an idempotency table for exactly-once handling. The signature stops forgeries; idempotency stops accidental and intentional replays.

Header-injection risks. Treat header inputs as untrusted bytes. Reject signatures with unexpected encodings, lengths, or formats before decoding. The verifier should reject on any parse error, not throw, so a malformed header never short-circuits the constant-time compare.

Algorithm hygiene. HMAC-MD5 and HMAC-SHA-1 are technically still unbroken as MACs, but using them invites audit findings and reads as a smell to anyone reviewing the code. Pick SHA-256 unless a legacy peer forces otherwise, and document the legacy reason in a comment next to the call site.

Closing

The right reach for HMAC is symmetric trust, integrity, and authenticity, with raw-body hashing and a constant-time compare. The boundary is also clear: password hashing, non-repudiation, asymmetric verifier pools, session tokens, and confidentiality each need a different primitive. If a webhook receiver, a signed URL issuer, or an internal RPC sits in front of you, the next step is to audit the compare. Replace any ==, ===, or strcmp against a MAC with timingSafeEqual, hmac.compare_digest, or hmac.Equal, then confirm the verifier reads raw bytes rather than a re-serialized JSON object.

References

Related Posts