CMPUT-331 · Lesson 4 of 12 · 37 min
Hash functions, message authentication codes and secure password storage
A cryptographic hash function turns any input, from a single byte to a whole disk image, into a short fixed-size fingerprint. Hashes are everywhere in security: they identify software releases and container images, they anchor digital signatures and certificates, they build message authentication codes, they make Git commits tamper-evident, and, used carefully, they protect stored passwords. This lesson explains what a good hash guarantees, how a message authentication code adds a secret key to give integrity and authenticity, and how to store passwords so that a database leak does as little damage as possible.
The lesson takes the defender's point of view throughout. Password storage is covered as a design problem: which algorithm, which parameters, what to store, how to verify and how to migrate old hashes. Lesson 3 covered keys and authenticated encryption; CISSP-114 covered authentication policy for managers, including multifactor authentication and passkeys. Here we look at the mechanisms underneath.
What a cryptographic hash function guarantees
A hash function \( H \) maps a message of any length to a digest of fixed length, 256 bits for SHA-256. To be useful for security it must have three properties. Preimage resistance: given a digest \( h \), it is infeasible to find any message \( m \) with \( H(m) = h \). Second-preimage resistance: given a message \( m_1 \), it is infeasible to find a different \( m_2 \) with the same digest. Collision resistance: it is infeasible to find any two different messages with the same digest. Collision resistance is the strongest and the hardest to keep; MD5 lost it in 2004 and SHA-1 in 2017, when researchers produced two different PDF files with the same SHA-1 digest. Both remain fine as non-security checksums but must not be used where an attacker chooses the inputs.
A good hash also behaves like a random function: a one-character change to the input changes about half of the output bits, and nothing about the input can be read from the output. The digest length is fixed no matter how large the input is.
import hashlib
msgs = [b"Pay Ana $100", b"Pay Ana $900", b"Pay Ana $100"]
for m in msgs:
print(f"{m.decode():<14} sha256 = {hashlib.sha256(m).hexdigest()[:32]}...")
a, b = hashlib.sha256(msgs[0]).digest(), hashlib.sha256(msgs[1]).digest()
diff = sum(bin(x ^ y).count("1") for x, y in zip(a, b))
print(f"one character changed -> {diff} of 256 digest bits differ")
print("same input, same digest:", hashlib.sha256(msgs[0]).digest() == hashlib.sha256(msgs[2]).digest())
big = b"x" * 10_000_000
print("10 MB input still gives", len(hashlib.sha256(big).digest()), "bytes of digest")Pay Ana $100 sha256 = f6847ce6f1eb838e518383d4468b4122... Pay Ana $900 sha256 = 4a25b4065579136cd436c042dbd81070... Pay Ana $100 sha256 = f6847ce6f1eb838e518383d4468b4122... one character changed -> 125 of 256 digest bits differ same input, same digest: True 10 MB input still gives 32 bytes of digest
The birthday bound
Collision resistance is limited by the birthday paradox. In a room of only 23 people, there is a better than even chance that two share a birthday, because the number of pairs grows with the square of the number of people. In the same way, a generic collision search on an \( n \)-bit digest needs only about \( 2^{n/2} \) evaluations, not \( 2^n \). That is why a 128-bit digest gives only 64 bits of collision security, too little today, and why 256-bit digests are the norm.
import math
# Probability that at least two of k random n-bit values collide: about 1 - exp(-k^2 / 2^(n+1))
def p_collision(k, bits):
return -math.expm1(-k * k / 2 ** (bits + 1))
for bits in (32, 64, 128, 256):
k50 = math.sqrt(2 * math.log(2)) * 2 ** (bits / 2)
print(f"{bits:>3}-bit digest: 50% collision chance after about 2^{math.log2(k50):.1f} values")
print()
for k in (10_000, 77_000, 1_000_000):
print(f"{k:>9,} values in a 32-bit space: P(collision) = {p_collision(k, 32):.3f}")32-bit digest: 50% collision chance after about 2^16.2 values 64-bit digest: 50% collision chance after about 2^32.2 values 128-bit digest: 50% collision chance after about 2^64.2 values 256-bit digest: 50% collision chance after about 2^128.2 values 10,000 values in a 32-bit space: P(collision) = 0.012 77,000 values in a 32-bit space: P(collision) = 0.499 1,000,000 values in a 32-bit space: P(collision) = 1.000
The same arithmetic matters outside hashing. Random identifiers, nonces and session tokens must be long enough that collisions are practically impossible across every value a system will ever generate. A 32-bit random ID is almost certain to collide somewhere among a million records, while a 128-bit random token from the secrets module will not collide in the lifetime of the universe.
Message authentication codes
A hash alone does not prove who produced a message: anyone can recompute it after changing the data. A message authentication code (MAC) adds a secret key shared by sender and receiver. Only someone with the key can produce a valid tag, so a correct tag shows that the message came from a key holder and was not changed. HMAC, standardized in RFC 2104 and FIPS 198-1, builds a MAC from any hash function by hashing the key and message twice with two derived pads:
The nested structure matters. The obvious shortcut, hashing the key followed by the message, is unsafe with SHA-256 and other Merkle–Damgård hashes because of a property called length extension: from the digest of a message, anyone can compute the digest of that message with extra data appended, without knowing the key. HMAC's construction is immune, as are SHA-3 and BLAKE2 in keyed mode. The practical rule is simple: use HMAC from your standard library, never a home-made key-plus-message hash.
import hmac, hashlib
key = b"demo-only shared secret; load real keys from a secrets manager"
def tag(msg: bytes) -> str:
return hmac.new(key, msg, hashlib.sha256).hexdigest()
def verify(msg: bytes, t: str) -> bool:
return hmac.compare_digest(tag(msg), t) # constant-time comparison
order = b"order=1042&amount=59.00¤cy=CAD"
t = tag(order)
print("tag :", t[:40] + "...")
print("genuine :", verify(order, t))
changed = order.replace(b"59.00", b"5.90")
print("changed :", verify(changed, t))
print("wrong key gives a different tag:", hmac.new(b"other key", order, hashlib.sha256).hexdigest() != t)tag : 5f63d4b45b9415a509df71ddc755f7494f2d3fae... genuine : True changed : False wrong key gives a different tag: True
Verification uses hmac.compare_digest rather than ==. An ordinary comparison stops at the first differing byte, so in principle the time it takes reveals how many leading bytes of a guess were correct. A constant-time comparison always examines every byte. The same function belongs anywhere a secret value is compared: API keys, password reset tokens, CSRF tokens and webhook signatures.
Where hashes and MACs are used
Why password storage is a special problem
Passwords are low-entropy secrets chosen by people. Even with good habits, many users pick passwords that are far easier to guess than a 128-bit key. If a site stores passwords in plain text, a single database leak exposes every account, and because people reuse passwords, accounts on other sites too. Encrypting passwords is also a poor answer, because whoever obtains the key, including an insider or an attacker with access to the server, can decrypt them all. The standard answer is to store a one-way verifier: a value computed from the password from which the password cannot feasibly be recovered, but which lets the server check a login attempt.
An ordinary fast hash such as SHA-256 is the wrong tool for this. It was designed to be fast, so an attacker holding a leaked table can test enormous numbers of candidate passwords per second on graphics processors. Defenders therefore use functions that are deliberately slow and, ideally, memory-hard, so that each guess costs real time and memory. Three further measures complete the design. A unique random salt per user, stored alongside the hash, means two users with the same password get different stored values and that precomputed tables are useless. A pepper, an additional secret kept outside the database (for example in a hardware security module or a secrets manager), means a database leak alone is not enough. And the cost parameters are stored with each hash so they can be raised over time.
The example below stores and verifies passwords with scrypt from the standard library. The stored string records the algorithm, the cost parameters, the salt and the derived key, a self-describing format similar to the one Argon2 and bcrypt libraries use. Fixed salts appear only so the printed output is reproducible.
import hashlib, hmac, base64
# Parameters for scrypt: N (cost, power of 2), r (block size), p (parallelism)
N, R, P = 2**14, 8, 1
def hash_password(password: str, salt: bytes) -> str:
dk = hashlib.scrypt(password.encode(), salt=salt, n=N, r=R, p=P, dklen=32)
return "scrypt$%d$%d$%d$%s$%s" % (N, R, P, base64.b64encode(salt).decode(), base64.b64encode(dk).decode())
def verify_password(password: str, stored: str) -> bool:
_, n, r, p, s, d = stored.split("$")
dk = hashlib.scrypt(password.encode(), salt=base64.b64decode(s), n=int(n), r=int(r), p=int(p), dklen=32)
return hmac.compare_digest(dk, base64.b64decode(d))
# Fixed salts so the printed output is reproducible; real code uses os.urandom(16) per user.
alice = hash_password("correct horse battery staple", b"salt-for-alice!!")
bob = hash_password("correct horse battery staple", b"salt-for-bob-123")
print("alice:", alice[:60] + "...")
print("bob :", bob[:60] + "...")
print("same password, different stored values:", alice != bob)
print("verify correct :", verify_password("correct horse battery staple", alice))
print("verify incorrect:", verify_password("correct horse battery stapler", alice))
print(f"memory per hash: 128 * r * N = {128 * R * N / 2**20:.0f} MiB")alice: scrypt$16384$8$1$c2FsdC1mb3ItYWxpY2UhIQ==$+YSqXXI5OlZ801rFdX... bob : scrypt$16384$8$1$c2FsdC1mb3ItYm9iLTEyMw==$lO5er5SPzDJuTKa5wA... same password, different stored values: True verify correct : True verify incorrect: False memory per hash: 128 * r * N = 16 MiB
The demonstration uses \( N = 2^{14} \) so the example runs quickly; production settings use a larger \( N \) such as \( 2^{17} \), which needs 128 MiB per hash. Tune parameters so that one verification takes roughly a quarter to half a second on your servers, then protect the login endpoint with rate limiting so that this cost cannot be turned into a denial of service.
Rehashing and migration
Because each stored hash records its own parameters, a system can raise its cost over time without forcing everyone to reset. After a successful login, when the plaintext password is briefly available, the server checks whether the stored hash uses old parameters or an old algorithm and, if so, rehashes with the current settings. For a legacy database of fast hashes, a common transitional technique is to wrap them: compute the new slow hash over the old hash for every user immediately, then replace the wrapped value with a direct hash at each user's next login. That way no weak hash stays exposed while waiting for users to return.
Password policy that helps rather than hinders
Password rules have changed. NIST SP 800-63B-4, finalized in August 2025, tells verifiers to require at least 15 characters when a password is the only authenticator and at least 8 when it is part of multifactor authentication, to accept at least 64 characters and all printable and Unicode characters, and to screen new passwords against lists of common, expected or breached values. It also tells verifiers not to impose composition rules such as one digit and one symbol, not to force periodic changes unless there is evidence of compromise, and not to use hints or knowledge-based questions. Long passphrases are easier to remember and harder to guess than short complex strings.
# A password checker that follows NIST SP 800-63B-4 (2025) rather than old composition rules
BLOCKLIST = {"password", "password123", "qwerty123", "letmein", "iloveyou", "welcome1",
"hsmuniversity", "123456789012345"}
def check(pw: str, multifactor: bool, context=("ana", "lima", "hsm")) -> list[str]:
problems = []
minimum = 8 if multifactor else 15
if len(pw) < minimum:
problems.append(f"shorter than {minimum} characters")
if len(pw) > 64:
problems.append("longer than our 64-character limit (allow at least 64)")
low = pw.lower()
if low in BLOCKLIST:
problems.append("appears on the blocklist of common or breached passwords")
if any(word in low for word in context):
problems.append("contains the user's name or the service name")
return problems or ["acceptable"]
tests = [("Summer2026!", False), ("Summer2026!", True), ("password123", True),
("violet canoe under the bridge", False), ("ana-lima-2026-xyz", False)]
for pw, mfa in tests:
print(f"{pw!r:<32} mfa={str(mfa):<5} -> {'; '.join(check(pw, mfa))}")'Summer2026!' mfa=False -> shorter than 15 characters 'Summer2026!' mfa=True -> acceptable 'password123' mfa=True -> appears on the blocklist of common or breached passwords 'violet canoe under the bridge' mfa=False -> acceptable 'ana-lima-2026-xyz' mfa=False -> contains the user's name or the service name
Screening against breached passwords raises a privacy question: how can a service check a password against a breach database without sending the password, or even its full hash, to a third party? The widely used answer is a k-anonymity range query. The client hashes the password, sends only the first five hexadecimal characters of the hash, receives every breached-hash suffix that shares that prefix, and compares locally. The service never learns which password was checked. The toy below shows the idea with a local list instead of a network service.
import hashlib
# Checking a password against a breach list without revealing it (the k-anonymity range idea).
# A local toy "service" stands in for a real breach-list API; no network call is made.
breached = ["123456", "password", "qwerty", "iloveyou", "dragon2020", "monkey", "letmein"]
service = {}
for p in breached:
h = hashlib.sha1(p.encode()).hexdigest().upper()
service.setdefault(h[:5], []).append(h[5:])
def range_query(prefix): # the service only ever sees 5 hex characters
return service.get(prefix, [])
def is_breached(candidate: str) -> bool:
h = hashlib.sha1(candidate.encode()).hexdigest().upper()
return h[5:] in range_query(h[:5]) # the full hash is compared locally
for pw in ("letmein", "violet canoe under the bridge"):
print(f"{pw!r:<32} prefix sent: {hashlib.sha1(pw.encode()).hexdigest().upper()[:5]} breached: {is_breached(pw)}")
print("each prefix covers about 1 in", 16**5, "of all possible hashes")'letmein' prefix sent: B7A87 breached: True 'violet canoe under the bridge' prefix sent: 1C67F breached: False each prefix covers about 1 in 1048576 of all possible hashes
Canadian guidance points the same way. The Canadian Centre for Cyber Security recommends passphrases of at least four words or 15 characters, multifactor authentication wherever possible, and password managers, and it advises against forced periodic changes. Under PIPEDA and provincial laws such as Quebec's Law 25, a breach of security safeguards that creates a real risk of significant harm must be reported to the regulator and to affected people, so a leaked table of weak hashes is a legal problem as well as a technical one.
Worked examples
Worked problem
Choosing a verifier for a legacy system
A ten-year-old intranet stores passwords as unsalted MD5 digests. The team cannot force a reset for 40,000 users this month. What should they do?
Show solution
- Treat the existing digests as exposed-risk material: unsalted fast hashes offer almost no protection if leaked.
- Wrap every stored value now: store Argon2id(salt, MD5(password)) with a fresh random salt per user, so no bare MD5 remains on disk.
- At each successful login, verify through the wrapped path, then replace the record with Argon2id(salt, password) directly and mark it migrated.
- Add rate limiting, breached-password screening for new passwords, and plan a reset for accounts that never log in again.
Answer: Wrap the old hashes in a slow salted function immediately, upgrade each record at the next login, and add rate limiting and screening.
Worked problem
Webhook integrity
A payment provider sends webhooks with a header containing HMAC-SHA256 of the request body under a shared secret. A developer verifies it by computing the HMAC and testing tag == header. What is wrong and what else should be checked?
Show solution
- Use hmac.compare_digest instead of == so verification takes the same time whatever the input.
- Compute the HMAC over the exact raw bytes received, before any JSON parsing or re-serialization changes them.
- Check a timestamp included in the signed data and reject old messages, so a captured webhook cannot be replayed later.
- Keep the shared secret in a secrets manager and support rotation with two valid secrets during changeover.
Answer: Constant-time comparison over the raw body, plus a signed timestamp to stop replays and a rotatable secret.
Worked problem
Sizing a random token
A service issues password-reset tokens and expects at most 10 million over its lifetime. Is a 64-bit random token enough, and how should tokens be stored?
Show solution
- By the birthday bound, collisions among 10 million 64-bit values have probability about \( k^2/2^{65} \approx 2.7 \times 10^{-6} \): small but not negligible across many systems.
- Guessing matters more than collisions: with a 64-bit space and a one-hour lifetime, online guessing is impractical if the endpoint is rate-limited, but 128 bits removes all doubt.
- Generate 128-bit or longer tokens with secrets.token_urlsafe(32), store only their SHA-256 hash, make them single-use, and expire them within an hour.
Answer: Use at least 128-bit random tokens, store only a hash, make them single-use and short-lived.
Common mistakes
MistakeMD5 and SHA-1 are fine for any integrity check.
InsteadUse SHA-256 or newer where an attacker could choose inputs; MD5 and SHA-1 collisions are practical.
MistakeHashing the key followed by the message gives a secure MAC.
InsteadUse HMAC (or a keyed SHA-3 or BLAKE2 mode); key-then-message hashing is vulnerable to length extension.
MistakeComparing tags with == is good enough.
InsteadCompare secrets with hmac.compare_digest so timing does not leak information.
MistakePasswords should be stored with SHA-256 because it is a secure hash.
InsteadUse a slow, salted, memory-hard function such as Argon2id or scrypt; fast hashes help attackers.
MistakeUsers must change passwords every 90 days and include a symbol.
InsteadFollow NIST SP 800-63B-4: require length, screen against breached lists, and change only on evidence of compromise.
Summary and key terms
- Cryptographic hashes are preimage-, second-preimage- and collision-resistant; MD5 and SHA-1 have lost collision resistance.
- The birthday bound means an n-bit digest gives about n/2 bits of collision security.
- A MAC such as HMAC adds a shared key to give integrity and authenticity; verify tags in constant time.
- Never build a MAC by hashing key and message together; length extension breaks it for SHA-2.
- Store passwords with a slow, salted, memory-hard function (Argon2id, scrypt, bcrypt or high-iteration PBKDF2) and record its parameters.
- Rehash on login to raise costs, and wrap legacy fast hashes immediately.
- NIST SP 800-63B-4 favours length and breach screening over composition rules and forced changes.
- Cryptographic hash function
- A function mapping any input to a fixed-size digest that is infeasible to invert or collide.
- Collision
- Two different inputs with the same digest.
- Birthday bound
- The fact that collisions appear after about 2^(n/2) random n-bit values.
- MAC
- Message authentication code: a keyed tag proving integrity and origin among key holders.
- HMAC
- A standard MAC construction that nests a hash function with two key-derived pads.
- Length extension
- A property of Merkle–Damgård hashes that lets anyone extend a message and compute its new digest.
- Salt
- A unique random value stored with each password hash so equal passwords hash differently.
- Pepper
- A secret added to password hashing and kept outside the database.
- Memory-hard function
- A function whose evaluation requires substantial memory, raising the cost of parallel guessing hardware.
- Constant-time comparison
- A comparison whose duration does not depend on where the inputs differ.
Practice problems
Work each problem on paper first, then reveal the answer to check.
Name the three security properties of a cryptographic hash.
Show answer
Preimage resistance, second-preimage resistance and collision resistance.
How many bits of collision security does SHA-256 provide?
Show answer
About 128 bits, by the birthday bound.
Why is MD5 unsuitable for code signing?
Show answer
Practical collisions let an attacker create two files with the same digest, one benign and one harmful.
What does a MAC prove that a plain hash does not?
Show answer
That the message came from someone holding the shared key and was not changed.
Why does verification use hmac.compare_digest?
Show answer
It runs in constant time, so response timing does not reveal how much of a guessed tag was correct.
What is the purpose of a per-user salt?
Show answer
To make equal passwords hash differently and make precomputed tables useless.
Where should a pepper be kept?
Show answer
Outside the database, for example in a secrets manager or hardware security module.
What memory does scrypt with r = 8 and N = 2^17 need per hash?
Show answer
128 x 8 x 131,072 bytes = 128 MiB.
What minimum password length does NIST SP 800-63B-4 require for single-factor use?
Show answer
15 characters (8 when the password is part of multifactor authentication).
In a k-anonymity range query, what does the service learn?
Show answer
Only a short hash prefix shared by many passwords, not which password was checked.
Lesson quiz · 5 questions
Score 60% or more to complete this lesson.
Create a free account to save your progress, take quizzes and earn a certificate.