~/blog/password-hashing.md

Password Hashing: Argon2, and Why Not MD5

· #security

A consumer GPU can compute somewhere north of ten billion MD5 hashes per second. That number alone should end the conversation, but it doesn’t, because “hash the password” sounds done the moment you write hashlib.md5(password.encode()).hexdigest(), and the code runs, and it looks right. It is not right. It was never right for passwords, even back when MD5 wasn’t already broken as a general-purpose hash.

The confusion is that MD5 and SHA-256 are fast by design — that’s the point, for checksums and file integrity. Fast is exactly the wrong property for password storage, because “fast to compute” and “fast to brute-force” are the same property viewed from two sides.

What a stolen hash actually costs an attacker

Assume your database leaks — it happens to well-run companies, not just careless ones. The attacker now has a list of hashes and, if you did this part right, a per-user salt. Their entire remaining job is: guess a password, hash it the same way you did, see if it matches.

With sha256(salt + password) and a GPU rig, they can try billions of guesses per second per hash. A password from the “top 10,000 passwords” list falls in microseconds. Even a decent 10-character password with mixed case and digits — nominally billions of combinations — falls in hours to a rented GPU cluster, because the attacker isn’t limited by your server, only by their own hardware.

# what NOT to do
import hashlib

def hash_password(password: str, salt: bytes) -> str:
    return hashlib.sha256(salt + password.encode()).hexdigest()

This is a correctness bug wearing a security algorithm’s clothes. The hash function isn’t broken. It’s just the wrong tool — you want a function that’s deliberately slow, and slow in a way that resists being sped up with custom hardware.

Salts solve one problem, not the one you think

A salt — random bytes stored alongside the hash, unique per user — stops one specific attack: rainbow tables, precomputed hash-to-password lookups. Without a salt, two users with the same password get the same hash, and an attacker who’s precomputed hash(x) for every common password x cracks both instantly by lookup. With a per-user salt, every hash is unique even for identical passwords, so precomputation doesn’t transfer across accounts.

What a salt does not do is slow anyone down. sha256(salt + password) is still a fast hash; the attacker just runs the same billions-per-second attack against each salted hash individually instead of against a shared table. Salting is necessary. It is not sufficient. You still need the hash itself to be slow.

Why Argon2 (and what “work factor” means)

Argon2 — specifically Argon2id, the hybrid variant — won the 2015 Password Hashing Competition and is the current recommendation from OWASP. Its design goal is the opposite of MD5’s: make each hash computation expensive on purpose, and expensive in a way that resists being cheapened by throwing custom silicon at it.

It does this with three tunable knobs:

  • time cost — how many passes over memory the algorithm makes
  • memory cost — how much RAM each hash computation requires
  • parallelism — how many threads it uses

The memory cost is the interesting one. GPUs are fast because they have thousands of cheap cores, but each core has little memory and little memory bandwidth per core. A hash function that requires, say, 64MB of RAM per computation means a GPU trying to run ten thousand parallel guesses would need 640GB of fast memory — it doesn’t have it. This is called being “memory-hard,” and it’s the property that makes GPU and ASIC cracking rigs far less effective against Argon2 than against a raw SHA function.

from argon2 import PasswordHasher

ph = PasswordHasher(
    time_cost=3,        # passes over memory
    memory_cost=65536,  # KiB — 64 MB per hash
    parallelism=4,
)

hashed = ph.hash("correct horse battery staple")
# stored: a self-describing string with algorithm, params, salt, and hash
# $argon2id$v=19$m=65536,t=3,p=4$<salt>$<hash>

try:
    ph.verify(hashed, "correct horse battery staple")
except Exception:
    # VerifyMismatchError on wrong password
    pass

Notice the salt isn’t a separate column you manage — Argon2’s output string embeds the salt and every parameter used, so verification doesn’t need you to remember what settings you hashed with. That also means you can tune the cost upward over time (as hardware gets faster) without breaking verification of hashes created under the old settings.

Picking the numbers

There’s no universal “correct” work factor — it’s a trade-off between login latency on your server and cost to an attacker. A reasonable starting point most guides converge on: tune the parameters so a single hash takes somewhere around 200–500ms on your actual production hardware, then measure and adjust. If login feels slow, you’re protecting your users more than you need to. If it’s instant, you probably aren’t protecting them enough. Re-benchmark whenever you change hardware — a work factor tuned for 2020-era CPUs is doing less work than you think on anything newer.

What I learned

Password hashing is one of the few places in security where the correct answer is “use the library, don’t write the primitive” without qualification. bcrypt and scrypt are acceptable if Argon2 isn’t available in your stack; MD5 and unsalted or single-round SHA are never acceptable for this job, full stop, regardless of how the hash looks. If you’re staring at a schema with a password_hash column and you can’t tell from the stored value which algorithm produced it, that’s the first thing to fix — a hash you can’t identify is a hash you can’t verify is still doing its job.

cd .. cd ~ (back to terminal)