Skip to main content
Cryptography · lesson 1 of 8
Cryptography beginner Lesson 1 of 8

Also part of: Security Fundamentals

Hashes and HMACs: What Each One Actually Proves

Run SHA-256 and HMAC in Python: one input bit flips half the digest, a recomputed hash fools a naive check, and only the keyed HMAC rejects the forgery.

By SkillByExample · Published

cryptography hashing sha-256 hmac integrity python

A hash and an HMAC both turn data into a short fixed-size value, and both are called “integrity checks”. They prove very different things, and confusing them is one of the most common cryptographic mistakes in real code. Every example here uses only Python’s standard library. Run them in a throwaway container with docker run --rm -it -v "$PWD":/work -w /work python:3.12-slim bash. They were run with Python 3.12.15 on Debian 13.7.

One bit in, half the digest out

A cryptographic hash maps any input to a fixed-size digest, and a tiny change to the input should change the output beyond recognition. This is the avalanche effect.

# avalanche.py
import hashlib

a = b"pay alice amount=100"
b = b"pay alice amount=101"          # '0' -> '1' flips one bit of the last byte
ha = hashlib.sha256(a).digest()
hb = hashlib.sha256(b).digest()
print(ha.hex())
print(hb.hex())

in_bits = bin(a[-1] ^ b[-1]).count("1")
out_bits = bin(int.from_bytes(ha) ^ int.from_bytes(hb)).count("1")
print(f"input bits changed:  {in_bits}")
print(f"output bits changed: {out_bits} of 256")
big = hashlib.sha256(b"x" * 10_000_000).digest()
print("digest of 10 MB is still", len(big), "bytes")
$ python avalanche.py
9b89376febcad4cb15ac5f543ab315951c5c917a2af3e1b4a6fd50f9f742c34a
c4fd9789e2670896acc81c3d31245e9fbfc107569916d41744dcbff64e78e621
input bits changed:  1
output bits changed: 131 of 256
digest of 10 MB is still 32 bytes

The ASCII codes for 0 and 1 are 0x30 and 0x31, so the two inputs differ in exactly one bit. The digests share nothing you can see, and 131 of 256 bits changed, close to the half you’d expect from random output. That’s what makes a hash useful as a fingerprint: you can’t tweak an input to nudge its digest towards a target. The digest is always 32 bytes, whether the input is 20 bytes or 10 MB.

A plain hash can’t stop someone who can recompute it

Here a sender attaches the SHA-256 of each message, and the receiver checks it. The question is who the check protects against.

# plain_hash.py
import hashlib

def send(msg: bytes) -> tuple[bytes, str]:
    return msg, hashlib.sha256(msg).hexdigest()

def receive(msg: bytes, digest: str) -> str:
    ok = hashlib.sha256(msg).hexdigest() == digest
    return "accepted" if ok else "rejected"

msg, digest = send(b"pay alice amount=100")
print("genuine:           ", receive(msg, digest))

# someone on the path rewrites the message AND recomputes the hash
evil = b"pay mallory amount=9000"
print("tampered, old hash:", receive(evil, digest))
print("tampered, new hash:", receive(evil, hashlib.sha256(evil).hexdigest()))
$ python plain_hash.py
genuine:            accepted
tampered, old hash: rejected
tampered, new hash: accepted

The third line is the failure. SHA-256 has no secret in it, so anyone who can change the message can compute a matching digest for their version, and the receiver accepts it. A hash detects accidental change, such as a flipped bit or a truncated download, and attacker changes only when the digest travels over a channel the attacker can’t touch. When the message and its digest travel together, the digest proves nothing about who wrote it.

An HMAC needs the key

HMAC (RFC 2104) is a standard construction that mixes a secret key into a hash. Without the key, nobody can produce a tag that verifies.

# hmac_tag.py
import hashlib, hmac, secrets

key = secrets.token_bytes(32)        # shared by sender and receiver only

def tag(msg: bytes) -> bytes:
    return hmac.new(key, msg, hashlib.sha256).digest()

def receive(msg: bytes, t: bytes) -> str:
    return "accepted" if hmac.compare_digest(tag(msg), t) else "rejected"

msg = b"pay alice amount=100"
t = tag(msg)
print("genuine:            ", receive(msg, t))

evil = b"pay mallory amount=9000"
print("tampered, old tag:  ", receive(evil, t))
print("tampered, sha256:   ", receive(evil, hashlib.sha256(evil).digest()))
guess = hmac.new(secrets.token_bytes(32), evil, hashlib.sha256).digest()
print("tampered, wrong key:", receive(evil, guess))
$ python hmac_tag.py
genuine:             accepted
tampered, old tag:   rejected
tampered, sha256:    rejected
tampered, wrong key: rejected

The attack from the previous example no longer works: a plain SHA-256 of the forged message isn’t a valid tag, and neither is an HMAC made with any other key. The key is generated fresh with secrets on each run; in a real system it comes from a secret store. Two details matter here:

  • Use hmac.new, not sha256(key + message). The home-made version is open to length-extension attacks with SHA-256. HMAC’s construction was designed to avoid them, so don’t invent your own.
  • Compare with hmac.compare_digest. Plain == can return as soon as one byte differs, so its timing leaks how much of a guessed tag was right. compare_digest takes the same time whatever the inputs.

Where a plain hash is the right tool

A hash is correct when the expected digest comes from somewhere the attacker can’t write: a vendor publishing a release checksum on its HTTPS site, or a manifest you stored separately.

# checksum_file.py
import hashlib, pathlib

def sha256_of(path: pathlib.Path) -> str:
    with path.open("rb") as f:
        return hashlib.file_digest(f, "sha256").hexdigest()

path = pathlib.Path("release.tar")
path.write_bytes(b"pretend this is a release archive\n" * 1000)
published = sha256_of(path)          # in real life: from the vendor's HTTPS page

print("download matches:", sha256_of(path) == published)

data = bytearray(path.read_bytes())
data[20_000] ^= 0x01                 # one bit flipped in transit or on disk
path.write_bytes(data)
print("after one bit flip:", sha256_of(path) == published)
$ python checksum_file.py
download matches: True
after one bit flip: False

hashlib.file_digest (Python 3.11+) streams the file, so it works on archives larger than memory. A single flipped bit in 34,000 bytes is caught. Using == is fine here, because a published checksum isn’t a secret. Here’s the summary of what each one proves:

ToolDetects accidentsStops someone who can rewrite data and check valueNeeds a secret
SHA-256yesonly if the digest comes over a trusted channelno
HMAC-SHA256yesyes, if they don’t have the keyshared key
Signature (lesson 5)yesyes, and anyone can verifyprivate key to sign

Avoid MD5 and SHA-1 for anything security-related: practical collisions exist for both. And for storing passwords, a fast hash of any kind is the wrong tool, as lesson 4 measures.

Practice

  1. Compare the avalanche across algorithms. Change avalanche.py to run the same one-bit change through sha256, sha3_256, blake2b and md5, and print how many bits changed out of each digest’s size.
Solution
# avalanche_algos.py
import hashlib

a, b = b"pay alice amount=100", b"pay alice amount=101"
for name in ["sha256", "sha3_256", "blake2b", "md5"]:
    ha = hashlib.new(name, a).digest()
    hb = hashlib.new(name, b).digest()
    bits = len(ha) * 8
    changed = bin(int.from_bytes(ha) ^ int.from_bytes(hb)).count("1")
    print(f"{name:9} {changed:3} of {bits} bits changed")
$ python avalanche_algos.py
sha256    131 of 256 bits changed
sha3_256  130 of 256 bits changed
blake2b   284 of 512 bits changed
md5        62 of 128 bits changed

Every one of them shows a good avalanche, MD5 included. MD5 isn’t broken because its output looks predictable; it’s broken because researchers can construct two different inputs with the same digest. The avalanche effect is necessary for a secure hash, but not sufficient.

  1. Verify a webhook signature. Services such as GitHub sign webhook bodies with an HMAC and send it in a header like sha256=<hex>. Write sign and verify functions and show that an edited body and a missing header are both rejected.
Solution
# webhook.py
import hashlib, hmac, secrets

SECRET = secrets.token_bytes(32)     # demo-only: per run; real ones live in a secret store

def sign(body: bytes) -> str:
    return "sha256=" + hmac.new(SECRET, body, hashlib.sha256).hexdigest()

def verify(body: bytes, header: str) -> bool:
    return hmac.compare_digest(sign(body), header)

body = b'{"event": "push", "ref": "main"}'
header = sign(body)
print(header[:23] + "...")
print("genuine:", verify(body, header))
print("edited: ", verify(body.replace(b"main", b"prod"), header))
print("no sig: ", verify(body, ""))
$ python webhook.py
sha256=be90e186e1c314d3...
genuine: True
edited:  False
no sig:  False

Verify the raw request bytes, before any JSON parsing. Re-serialising parsed JSON can change whitespace or key order, and then even a genuine body fails the check.

  1. Reject a replayed message. A valid message and its tag can be captured and sent again, and the HMAC still verifies. Put a random message ID inside the tagged data, and have the receiver refuse an ID it has already seen.
Solution
# replay.py
import hashlib, hmac, secrets

key = secrets.token_bytes(32)
seen: set[str] = set()

def tag(msg: bytes) -> bytes:
    return hmac.new(key, msg, hashlib.sha256).digest()

def receive(msg: bytes, t: bytes) -> str:
    if not hmac.compare_digest(tag(msg), t):
        return "rejected: bad tag"
    msg_id = msg.split(b";")[0].decode()
    if msg_id in seen:
        return "rejected: replay"
    seen.add(msg_id)
    return "accepted"

msg = f"id={secrets.token_hex(8)};pay alice amount=100".encode()
t = tag(msg)
print(receive(msg, t))
print(receive(msg, t))               # the same bytes, captured and sent again
$ python replay.py
accepted
rejected: replay

The ID is inside the tagged bytes, so an attacker can’t change it without breaking the tag. Real systems usually add a timestamp too, so the receiver only has to remember IDs for a short window.

  1. Use keyed BLAKE2 as a MAC. BLAKE2 has a built-in key parameter, so it can act as a MAC without the HMAC wrapper. Rewrite tag with hashlib.blake2b(..., key=key, digest_size=32) and check a genuine and a tampered message.
Solution
# blake2_mac.py
import hashlib, hmac, secrets

key = secrets.token_bytes(32)

def tag(msg: bytes) -> bytes:
    return hashlib.blake2b(msg, key=key, digest_size=32).digest()

msg = b"pay alice amount=100"
t = tag(msg)
print("tag length:", len(t))
print("genuine: ", hmac.compare_digest(tag(msg), t))
print("tampered:", hmac.compare_digest(tag(b"pay alice amount=900"), t))
$ python blake2_mac.py
tag length: 32
genuine:  True
tampered: False

Keyed BLAKE2 is a vetted MAC mode of the hash, documented in RFC 7693, unlike sha256(key + message). Use HMAC-SHA256 when you need to interoperate, because every platform has it, and keyed BLAKE2 when both ends are yours and speed matters.

Frequently Asked Questions

What is the difference between a hash and an HMAC?
A hash such as SHA-256 is a public function: anyone can compute it for any data, so it detects accidental change but proves nothing about who produced the data. An HMAC mixes a secret key into the computation, so only someone holding the key can produce a valid tag. A hash gives integrity against accidents, an HMAC gives integrity and authenticity against an attacker.
Can a SHA-256 checksum stop an attacker from changing a message?
Not on its own. If the attacker can change the message and the checksum together, they simply recompute SHA-256 over their version and the check passes. A plain checksum only protects you when it arrives over a channel the attacker cannot touch, such as a vendor's HTTPS download page. Otherwise use an HMAC or a digital signature.
Why use hmac.compare_digest instead of == to check a tag?
The == operator on bytes can stop at the first byte that differs, so the time it takes leaks how much of a guessed tag was right. hmac.compare_digest takes the same time whatever the inputs, which removes that timing side channel. Use it whenever you compare a secret value such as a MAC tag, token or API key.
Are MD5 and SHA-1 still safe to use?
Not for anything security-related. Practical collisions exist for both, so an attacker can craft two different inputs with the same digest. They are still fine as non-security checksums, such as cache keys, but for integrity and signatures use SHA-256, SHA-3 or BLAKE2.
Does an HMAC prevent replay attacks?
No. An HMAC proves a message was produced by a key holder and not changed, but a captured message and its valid tag can be sent again and will verify again. To stop replays, include a unique message ID or timestamp inside the tagged data and have the receiver reject IDs it has already seen or timestamps that are too old.