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)| Construction | Safe | Why |
|---|---|---|
SHA256(message) | No | No key; anyone can recompute |
SHA256(key || message) | No | Length extension recovers a valid tag for an extended message |
SHA256(message || key) | Weak | Vulnerable to collisions in the hash; also fragile if impl truncated |
| HMAC-SHA256 | Yes | Two keyed hashes with inner and outer padding |
| SHA-256 truncated to 96 bits | Yes for a tag | Truncation is standard and saves bytes |
| SHA-3 or BLAKE2 keyed mode | Yes | Sponge 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.
Related
Collisions, birthday attacks and length extension Digital signatures and certificates
Last refreshed 2026-09-18.