Cryptography · lesson 4 of 8
Beginner
2Intermediate
5Also part of Cryptography
9- + Python Security from Python
- + Java Security from Java
- + Data Security and PII: Hashing, Masking, and Tokenization from Data Engineering
- + FastAPI Authentication: Password Hashing, OAuth2 and JWT from FastAPI
- + JWT Authentication in Spring Boot from Java
- + JavaScript Security Best Practices from JavaScript
- + Secrets: Real Log Masking, and the Base64 Trick That Defeats It from GitHub Actions
- + sensitive = true: What It Hides, and What It Doesn't from Terraform
- + Variable Precedence: host_vars Beats group_vars, and extra-vars Beats Everything from Ansible
Also part of: Ethical Hacking (methodology, lab-only)
Password Hashing Work Factors, Measured
Time SHA-256, PBKDF2, bcrypt, scrypt and argon2id on your own machine to see the guesses per second each allows, then store and upgrade argon2id hashes.
By SkillByExample · Published
When a password database leaks, what decides how many accounts fall is how many guesses per second
an attacker can test against each hash. This lesson measures that number on your own machine, by timing
your own calls to each algorithm. It uses no cracking tools and no wordlists. Start a container with
docker run --rm -it -v "$PWD":/work -w /work python:3.12-slim bash, then run
pip install argon2-cffi==25.1.0 bcrypt==5.0.0. The runs below used Python 3.12.15, argon2-cffi 25.1.0
and bcrypt 5.0.0 in Docker on a 12-core laptop. Your timings will differ, but the ratios won’t change much.
The mistake: a fast, unsalted hash
Here three users’ passwords are stored as plain SHA-256, and the script then times how fast SHA-256 runs.
# fast_hash.py
# The mistake: storing passwords as a plain, unsalted SHA-256.
import hashlib, time
users = {"alice": "correct-horse-7", "bob": "Summer2026!", "carol": "Summer2026!"}
for name, pw in users.items():
print(f"{name:6}", hashlib.sha256(pw.encode()).hexdigest()[:24], "...")
n = 1_000_000
start = time.perf_counter()
for i in range(n):
hashlib.sha256(b"candidate-%d" % i).digest()
elapsed = time.perf_counter() - start
print(f"\n{n:,} SHA-256 hashes in {elapsed:.2f} s on one core")
print(f"= {n / elapsed:,.0f} guesses per second, in pure Python")
$ python fast_hash.py
alice 3dc8bc276833c21890daf7f3 ...
bob a5c95e887dbf1c0a49fd01bc ...
carol a5c95e887dbf1c0a49fd01bc ...
1,000,000 SHA-256 hashes in 0.48 s on one core
= 2,081,818 guesses per second, in pure Python
There are two failures here. First, bob and carol have identical stored hashes, so anyone reading the
table knows they share a password, and cracking one cracks both. With no salt, a single precomputed table
of common-password hashes works against every user at once. Second, the speed: two million guesses per
second on one core, from a Python loop. Compiled code on a GPU is far faster again. SHA-256 is
designed to be fast, and that’s exactly the wrong property for storing passwords.
The fix, measured: a work factor
Password hashes are slow on purpose, and the slowness is a parameter. This script times each one for at least a second and converts the result into guesses per second.
# work_factors.py
import hashlib, os, time
import bcrypt
from argon2 import PasswordHasher
pw, salt = b"correct-horse-7", os.urandom(16)
ph = PasswordHasher() # argon2id, t=3, m=64 MiB, p=4
candidates = {
"sha256 (no salt)": lambda: hashlib.sha256(pw).digest(),
"pbkdf2-sha256 600k": lambda: hashlib.pbkdf2_hmac("sha256", pw, salt, 600_000),
"bcrypt cost 12": lambda: bcrypt.hashpw(pw, bcrypt.gensalt(12)),
"scrypt n=2^17 r=8": lambda: hashlib.scrypt(pw, salt=salt, n=2**17, r=8, p=1,
maxmem=256 * 2**20),
"argon2id default": lambda: ph.hash(pw),
}
print(f"{'algorithm':20}{'ms per hash':>12}{'guesses/s':>12}")
for name, fn in candidates.items():
runs, start = 0, time.perf_counter()
while (elapsed := time.perf_counter() - start) < 1 or runs < 3:
fn()
runs += 1
ms = elapsed / runs * 1000
print(f"{name:20}{ms:12.4f}{1000 / ms:12,.0f}")
$ python work_factors.py
algorithm ms per hash guesses/s
sha256 (no salt) 0.0006 1,805,447
pbkdf2-sha256 600k 84.5970 12
bcrypt cost 12 202.9042 5
scrypt n=2^17 r=8 254.0403 4
argon2id default 49.8324 20
A login that takes 50 to 250 ms is unnoticeable for a user. For someone testing guesses against a leaked hash, it cuts the rate from about 1.8 million per second to between 4 and 20, a factor of 100,000 or more. The parameters are the ones the OWASP Password Storage Cheat Sheet names (PBKDF2’s 600,000 iterations, scrypt’s N=2¹⁷) and the libraries’ defaults. Two more differences don’t show up in a timing table:
- Memory hardness. scrypt and argon2id also need a lot of memory per guess: 128 MiB and 64 MiB here. GPUs and custom chips can run thousands of SHA-256 or PBKDF2 computations in parallel cheaply, but not thousands of 64 MiB computations. That’s why argon2id and scrypt rank above PBKDF2 and bcrypt.
- Salt. Every row except SHA-256 takes a random salt, so equal passwords give different hashes.
OWASP’s current order of preference is argon2id, then scrypt, then bcrypt for legacy systems, then PBKDF2 where FIPS-140 compliance requires it.
Storing and checking an argon2id hash
argon2-cffi’s PasswordHasher handles the salt, the parameters and the encoding for you.
# argon2_store.py
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
ph = PasswordHasher()
stored_bob = ph.hash("Summer2026!")
stored_carol = ph.hash("Summer2026!") # same password, different user
print(stored_bob)
print("bob == carol in the database?", stored_bob == stored_carol)
print("login with right password:", ph.verify(stored_bob, "Summer2026!"))
try:
ph.verify(stored_bob, "summer2026!")
except VerifyMismatchError as e:
print("login with wrong password:", type(e).__name__, "-", e)
$ python argon2_store.py
$argon2id$v=19$m=65536,t=3,p=4$drJmNBlS+0V6O/hK4ZlXtw$biRkKtSqrnFvffkiQCSc2A0fTCLoAaFxI+XX59Okqzw
bob == carol in the database? False
login with right password: True
login with wrong password: VerifyMismatchError - The password does not match the supplied hash
The stored string contains everything verify needs: the algorithm (argon2id), the version, the
parameters (64 MiB, 3 passes, 4 lanes), the base64 salt and the hash. So there’s one column to store and
nothing to keep in sync. Bob and Carol now have different hashes for the same password, because each call
drew its own salt. A wrong password raises VerifyMismatchError rather than returning False, so a
forgotten if can’t let a wrong password through. For wiring this into a web app’s login, see
FastAPI password hashing and JWT.
Raising the work factor without a password reset
Hardware gets faster, so parameters should go up over time. You can’t rehash a stored hash without the password, but each login gives you the password for a moment.
# rehash.py
from argon2 import PasswordHasher
old = PasswordHasher(time_cost=1, memory_cost=8 * 1024, parallelism=1)
new = PasswordHasher() # today's stronger defaults
stored = old.hash("correct-horse-7") # created years ago
print("stored: ", stored[:32], "...")
def login(password: str) -> None:
global stored
new.verify(stored, password) # verify reads params from the hash
if new.check_needs_rehash(stored):
stored = new.hash(password) # upgrade while we have the plaintext
print("rehashed: ", stored[:32], "...")
login("correct-horse-7")
print("needs rehash now?", new.check_needs_rehash(stored))
$ python rehash.py
stored: $argon2id$v=19$m=8192,t=1,p=1$OS ...
rehashed: $argon2id$v=19$m=65536,t=3,p=4$9 ...
needs rehash now? False
new.verify accepts the old hash, because the parameters are read from the string, not from the
PasswordHasher. check_needs_rehash then compares those parameters with the current ones and reports
that this hash is weaker (8 MiB, 1 pass), so it’s replaced on the spot. Users upgrade themselves one login
at a time, without any reset or migration downtime.
Practice
- Watch bcrypt’s cost double. Hash the same password with bcrypt costs 10 to 13 and time each. What does each +1 do?
Solution
# bcrypt_cost.py
import time
import bcrypt
for cost in range(10, 14):
salt = bcrypt.gensalt(cost)
start = time.perf_counter()
hashed = bcrypt.hashpw(b"correct-horse-7", salt)
ms = (time.perf_counter() - start) * 1000
print(f"cost {cost}: {ms:7.1f} ms {hashed[:29].decode()}...")
$ python bcrypt_cost.py
cost 10: 53.9 ms $2b$10$uKiTx3ythpNEmxyClR96He...
cost 11: 93.2 ms $2b$11$lzCT.vguvDArOAnjLUhirO...
cost 12: 194.2 ms $2b$12$msr.aoUnAciOmFScG.R0LO...
cost 13: 405.2 ms $2b$13$LiSiYAU7HOfUdwLdB6cakO...
The cost is a power of two: bcrypt runs 2^cost rounds of key setup, so each +1 roughly doubles the time.
Like argon2’s string, the bcrypt string carries its cost ($2b$12$) and salt, so old and new costs can
coexist in one table.
- Find bcrypt’s length limit. Hash a 72-byte password and a 73-byte password with bcrypt 5.0. What happens, and what should an application do about it?
Solution
# bcrypt_72.py
import bcrypt
long_pw = b"x" * 72
hashed = bcrypt.hashpw(long_pw, bcrypt.gensalt())
print("72 bytes hashed:", hashed[:7].decode(), "...")
try:
bcrypt.hashpw(b"x" * 73, bcrypt.gensalt())
except ValueError as e:
print("73 bytes:", type(e).__name__, "-", e)
$ python bcrypt_72.py
72 bytes hashed: $2b$12$ ...
73 bytes: ValueError - password cannot be longer than 72 bytes, truncate manually if necessary (e.g. my_password[:72])
bcrypt only uses the first 72 bytes of a password. Older versions of the library silently ignored the rest, so two long passphrases sharing a 72-byte prefix verified as each other. bcrypt 5.0 raises an error instead. Counting in bytes matters too: a 30-character password in a non-Latin script can be over 72 bytes in UTF-8. argon2id has no such limit, which is one more reason to prefer it for new systems.
- Tune argon2id for your server. Time
PasswordHasher(time_cost=2, memory_cost=..., parallelism=1)at 19, 64, 128 and 256 MiB. Which setting would you choose for a login budget of about 100 ms?
Solution
# tune_argon2.py
import time
from argon2 import PasswordHasher
for mib in [19, 64, 128, 256]:
ph = PasswordHasher(time_cost=2, memory_cost=mib * 1024, parallelism=1)
start = time.perf_counter()
ph.hash("correct-horse-7")
ms = (time.perf_counter() - start) * 1000
print(f"m={mib:3} MiB t=2 p=1: {ms:6.1f} ms")
$ python tune_argon2.py
m= 19 MiB t=2 p=1: 18.1 ms
m= 64 MiB t=2 p=1: 72.9 ms
m=128 MiB t=2 p=1: 159.3 ms
m=256 MiB t=2 p=1: 321.6 ms
Time grows roughly in line with memory. On this machine, 64 MiB fits a 100 ms budget. 19 MiB is OWASP’s minimum, not a target. Also check the memory side: 64 MiB per login and 50 logins at once is 3.2 GB of RAM. Pick the largest setting your server can afford under peak login load, and measure on production hardware, not a laptop.
- Salt PBKDF2 yourself. Using only
hashlib, store(salt, hash)pairs withpbkdf2_hmac("sha256", ..., 600_000)for two users with the same password, and verify withhmac.compare_digest.
Solution
# pbkdf2_salt.py
import hashlib, hmac, os
ITER = 600_000
def make(password: str) -> tuple[bytes, bytes]:
salt = os.urandom(16)
return salt, hashlib.pbkdf2_hmac("sha256", password.encode(), salt, ITER)
def check(password: str, salt: bytes, stored: bytes) -> bool:
fresh = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, ITER)
return hmac.compare_digest(fresh, stored)
bob, carol = make("Summer2026!"), make("Summer2026!")
print("same stored hash?", bob[1] == carol[1])
print("bob, right:", check("Summer2026!", *bob))
print("bob, wrong:", check("Summer2027!", *bob))
$ python pbkdf2_salt.py
same stored hash? False
bob, right: True
bob, wrong: False
This works, but notice how much you had to get right: the salt length, the iteration count, storing them
both, and a constant-time compare. If you later raise ITER, every existing hash fails to verify unless you
also stored the old count. That’s why a library format like argon2’s, which records the parameters in
the string, is the better default.