Skip to main content
Cryptography · lesson 4 of 8
Cryptography intermediate Lesson 4 of 8

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

cryptography passwords argon2 bcrypt pbkdf2 scrypt python

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

  1. 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.

  1. 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.

  1. 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.

  1. Salt PBKDF2 yourself. Using only hashlib, store (salt, hash) pairs with pbkdf2_hmac("sha256", ..., 600_000) for two users with the same password, and verify with hmac.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.

Frequently Asked Questions

Why shouldn't I hash passwords with SHA-256?
SHA-256 is designed to be fast, and fast is exactly wrong for passwords. If a database of SHA-256 password hashes leaks, an attacker can test millions of guesses per second per CPU core, and far more on GPUs, so common passwords fall almost immediately. Without a salt, users with the same password also get the same hash. Use a slow, salted password hash such as argon2id instead.
What is a work factor in password hashing?
A work factor is a tunable cost that makes each hash deliberately slow: an iteration count for PBKDF2, a cost for bcrypt, or time and memory parameters for scrypt and argon2id. Your server pays it once per login, but an attacker pays it for every guess. Raising it as hardware gets faster keeps guessing expensive.
Which password hashing algorithm should I use in 2026?
OWASP's Password Storage Cheat Sheet recommends argon2id first, with a minimum of 19 MiB memory, 2 iterations and parallelism 1. Use scrypt if argon2id isn't available, bcrypt with cost 10 or more for legacy systems, and PBKDF2-HMAC-SHA256 with 600,000 iterations where FIPS compliance requires it. Always use a maintained library rather than writing the scheme yourself.
What does a salt do in password hashing?
A salt is a random value, unique per password, that is mixed into the hash and stored next to it. It makes two users with the same password get different stored hashes, and it means an attacker can't precompute one table of hashes and use it against every user. A salt isn't secret and doesn't slow down guessing a single hash; the work factor does that.
How do I upgrade password hashes to stronger parameters?
You can't rehash without the plaintext, so upgrade at login. After a successful verify, call a check such as argon2-cffi's check_needs_rehash; if the stored hash used older or weaker parameters, hash the password again with the current settings and save it. Users who never log in keep the old hash until you force a reset.