HMAC and keyed hashing

Why a plain hash cannot authenticate a message, how the HMAC construction fixes the length-extension problem, constant-time comparison, and the misuse patterns to avoid.

A hash proves integrity only against accident

Anyone can compute a SHA-256 of anything. Appending the digest to a message proves only that the message was not corrupted by noise, because an attacker who changes the message recomputes the digest.

import hashlib

message = b"transfer=100&to=alice"
naive = hashlib.sha256(message).hexdigest()

# the attacker edits the message and recomputes — nothing detects it
forged = b"transfer=9999&to=alice"
attacker_digest = hashlib.sha256(forged).hexdigest()   # valid-looking, worthless

# even the secret-prefix construction is broken: length extension
broken = hashlib.sha256(b"secret" + message).hexdigest()

Merkle-Damgard hashes such as MD5, SHA-1 and SHA-2 process data in blocks and expose their internal state, so an attacker who knows a digest can compute a valid digest for a longer message. That is length extension, and HMAC exists to prevent it.

The HMAC construction

import hmac, hashlib, secrets

key = secrets.token_bytes(32)
msg = b"transfer=100&to=alice"

tag = hmac.new(key, msg, hashlib.sha256).digest()
print(tag.hex())

# verifying: compare in constant time, never with ==
def verify(key, msg, tag) -> bool:
    expected = hmac.new(key, msg, hashlib.sha256).digest()
    return hmac.compare_digest(expected, tag)

assert verify(key, msg, tag)
assert not verify(key, b"transfer=9999&to=alice", tag)
ConstructionSafeWhy
SHA256(message)NoNo key; anyone can recompute
SHA256(key || message)NoLength extension recovers a valid tag for an extended message
SHA256(message || key)WeakVulnerable to collisions in the hash; also fragile if impl truncated
HMAC-SHA256YesTwo keyed hashes with inner and outer padding
SHA-256 truncated to 96 bitsYes for a tagTruncation is standard and saves bytes
SHA-3 or BLAKE2 keyed modeYesSponge or native keying, no length extension
# compute an HMAC on the command line
printf 'transfer=100&to=alice' | openssl dgst -sha256 -hmac "$(cat secret.key)"
# HMAC-SHA256(stdin)= 4f2a...

Where HMAC use goes wrong

  • Comparing tags with == — a byte-by-byte compare leaks timing. Use a constant-time function.
  • Reusing a key across purposes — derive separate keys for signing and for encryption with a KDF.
  • Not covering the whole message — a tag over the body but not the headers lets an attacker edit the headers.
  • Ignoring the algorithm field — a token that names its own algorithm invites an attacker to downgrade it.
  • No expiry — a tag with no timestamp is valid forever, so a leaked token cannot be revoked.
# sign a structured token: every field that matters is inside the tag
import base64, hmac, hashlib, json, time

def sign(payload: dict, key: bytes) -> str:
    body = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode()
    b64 = base64.urlsafe_b64encode(body).rstrip(b"=")
    tag = hmac.new(key, b64, hashlib.sha256).digest()
    return (b64 + b"." + base64.urlsafe_b64encode(tag).rstrip(b"=")).decode()

def unsign(token: str, key: bytes, max_age=300) -> dict:
    b64, _, sig = token.partition(".")
    expected = hmac.new(key, b64.encode(), hashlib.sha256).digest()
    given = base64.urlsafe_b64decode(sig + "=" * (-len(sig) % 4))
    if not hmac.compare_digest(expected, given):
        raise ValueError("bad signature")
    payload = json.loads(base64.urlsafe_b64decode(b64 + "=" * (-len(b64) % 4)))
    if time.time() - payload["iat"] > max_age:
        raise ValueError("expired")
    return payload
⚠️
An HMAC proves the message came from someone holding the key; it does not hide the message. If confidentiality is needed, encrypt as well and apply the tag to the ciphertext, and derive independent keys for the two purposes.

FAQ

Should I use HMAC or a digital signature?
HMAC when both sides share a secret, which is simpler and faster. A signature when the verifier must not be able to forge, such as a public API or a software release.
How long should an HMAC tag be?
128 bits is a common and safe choice for interactive use. Truncating below 96 bits starts to matter against an online attacker; below 64 bits it is generally unsafe.

Collisions, birthday attacks and length extension Digital signatures and certificates

Last refreshed 2026-09-18.