Storing passwords: why not hash them with SHA-256, and how slow on purpose Argon2id is

If the database leaks, are the passwords still safe? Rainbow tables and salts, measuring SHA-256 at 4.3 million hashes/second against Argon2id at 44/second, estimating cracking time, a sign-in that doesn't reveal whether an email exists, and today's password policy.

The previous post concluded that SHA-256 hashes over 3 GB per second, and that this is a good thing. This post shows that the very same property is a disaster when storing passwords.

The question isn't "how do we keep the database from leaking" (that needs doing too), but: when the database leaks, through a forgotten backup, an SQL injection, a former employee, how long do users' passwords stay safe? It matters because people reuse passwords: a password leaked from your site may be the password to their bank email.

Roadmap: the wrong ways to store passwords (plain text, encryption, fast hashing), rainbow tables and salts, deliberately slow hash functions (bcrypt, scrypt, Argon2id), measuring speed and estimating cracking time, how this site hashes and checks passwords, a sign-in that leaks nothing, then password policy, MFA and passkeys. Measured on a real machine with Python 3.12, argon2-cffi and bcrypt.

Three wrong ways

Plain text. Leak the database and you leak everything. It sounds obvious but it still happens: in 2019, Facebook admitted hundreds of millions of passwords had been written in plain text to internal logs. Logs, backups and error trackers are where passwords tend to slip in.

Encrypting passwords. Encryption can be decrypted, and the key usually sits on the same server. Whoever gets the database often gets the key too. In 2013, Adobe leaked 153 million accounts whose passwords were encrypted with 3DES in ECB mode, which encrypts each block separately: the same password gave the same ciphertext, and password hints sat in plain text right next to them. People worked out most of the passwords without the key.

Fast hashing, no salt. sha256(password) is one-way, but it has two problems:

no salt  : 8d969eef6ecad3c2 8d969eef6ecad3c2

Two users who both pick 123456 have the same hash. One look at the database shows who shares a password, and the hash of 123456 is one Google search away. That's the first problem. The second is speed, below.

Rainbow tables and salts

Since a password's hash is always the same, an attacker can precompute hashes for billions of common passwords once, then look them up for every leaked database. A rainbow table is a compressed structure for that lookup table.

A salt is a random string unique to each user, hashed together with the password and stored right next to the hash. A salt isn't secret. Its job:

  • Two people with the same password get different hashes.
  • Precomputed tables become useless: to precompute you'd need to do it per salt, so it's no longer "pre".
  • The attacker must crack one account at a time, not the whole database at once.

A salt doesn't slow down cracking a single account. A weak password still falls fast if the hash is fast. To stop that, the hash has to be slow.

One more layer is a pepper: a shared secret key kept outside the database (an environment variable, a KMS), mixed in with HMAC before hashing. Someone with only the database and no pepper can't crack anything. It's useful, but adds key management (changing the pepper means waiting for everyone to sign in again), so many systems, this site included, don't use one yet.

Slow on purpose

Passwords differ from all other data in one way: the space is tiny. People don't pick at random from 2²⁵⁶ possibilities; they pick names, birthdays, dictionary words, keyboard patterns. The attacker doesn't need to break SHA-256, just to guess fast. So a password hash must be designed to be slow and resource-hungry, with an adjustable cost:

Function Year Tunable Notes
PBKDF2 2000 Iteration count Repeats HMAC hundreds of thousands of times. Uses no memory, so GPUs parallelise it very well
bcrypt 1999 cost (each +1 doubles the time) Uses 4 KB of memory, enough to bother GPUs a little. Only reads the first 72 bytes of the password
scrypt 2009 CPU, memory The first function deliberately memory-hungry
Argon2id 2015 time, memory, threads Won the Password Hashing Competition. OWASP's first recommendation

The key idea of scrypt and Argon2 is being memory-hard. A GPU has thousands of cores but very little memory per core. If each hash needs 64 MB of RAM, a 24 GB GPU can only run about 380 hashes in parallel instead of tens of thousands. Custom hardware (ASICs) also loses its edge, since memory costs more than arithmetic circuits.

The "id" in Argon2id means a hybrid: the first half resists side-channel attacks (timing memory accesses), the second half resists GPUs. It's the variant to use.

Measured: how fast, how slow

Each function running for a while on one CPU core:

Function Hashes / second Per hash
SHA-256 4,289,985 0.0002 ms
bcrypt cost 10 25.0 40.0 ms
bcrypt cost 12 6.2 162.4 ms
Argon2id m=19 MiB, t=2, p=1 (OWASP minimum) 78.0 12.8 ms
Argon2id m=64 MiB, t=3, p=4 (this site) 43.8 22.8 ms

Argon2id with this site's parameters is about 100,000 times slower than SHA-256. For a real user, 23 ms at sign-in is imperceptible. For someone cracking passwords, it's the difference between hours and centuries:

Password Possibilities Exhaust with SHA-256 Exhaust with Argon2id
8 lowercase letters 2.1 × 10¹¹ 13.5 hours 151 years
8 of any printable character 6.6 × 10¹⁵ 49 years 4.8 million years
4 random words (7,776-word list) 3.7 × 10¹⁵ 27 years 2.7 million years

Two notes for reading the table correctly:

  • This is one CPU core running Python. Real attackers use GPUs, many machines and dedicated software like hashcat, orders of magnitude faster against SHA-256. Against Argon2id the gap is much narrower because of memory, which is the whole point. Read the times as a "ratio", not absolute numbers.
  • Nobody exhausts the space. Attackers guess in order of likelihood: lists of leaked passwords (billions of entries), dictionaries, variants (Password@2026). 123456 is found on the first guess with any hash function. A slow hash only protects passwords not near the top of the list.

The last row deserves attention too: 4 random words like horse-battery-staple-clip are as strong as 8 chaotic characters like k#9Lq!2x, and much easier to remember. The key word is random, chosen by a machine, not thought up by a person.

How this site hashes and checks passwords

viet.sarl's passwords use pwdlib with its recommended configuration:

password_hash = PasswordHash.recommended()  # Argon2id

The result stored in the password_hash column is in the standard PHC format, describing everything needed to check it again:

$argon2id$v=19$m=65536,t=3,p=4$FhQAgDXAUPiL1lk487RfZg$QgrBgUbAcVtU5fbxJpdWHi21jVNjMA0es0uyq08+uts
 algorithm  version  64 MiB memory, 3 passes, 4 lanes   salt (16 bytes)         hash (32 bytes)

Hashing 123456 twice gives two completely different strings because the salts differ. Since the parameters live in the string, raising the cost later just means changing the configuration: old hashes still verify with old parameters, and when a user signs in successfully, the password is rehashed with the new ones (pwdlib has verify_and_update for this).

A hash costs 23 ms of CPU, so this site runs it on a separate thread (run_in_threadpool) so it doesn't block the async server's event loop, just as the post on threads and the GIL said about heavy computation in async code.

A sign-in that doesn't reveal whether an email exists

Sign-in forms have a classic leak. If the email doesn't exist, the server answers immediately; if it does, it has to run 23 ms of Argon2id before answering "wrong password". Even with identical error messages, the timing differs. Measured:

Case No dummy hash With dummy hash
Email exists, wrong password 22.03 ms 22.08 ms
Email doesn't exist 0.000 ms 21.81 ms

With no countermeasure, timing alone tells which emails are registered: a list worth having for phishing, and the first step toward guessing passwords. This site checks against a dummy hash when no user is found, so both cases take the same time:

# Verified against when the email is unknown, so a miss takes as long as a wrong password.
_DUMMY_HASH = password_hash.hash(secrets.token_urlsafe())
...
hashed = user.password_hash if user and user.password_hash else _DUMMY_HASH
ok = await run_in_threadpool(password_hash.verify, password, hashed)

Both cases get the same "wrong email or password" message, and the forgot-password page shows the same message whether or not the email exists. The sign-up page can't hide it (an email already in use must be reported as such), so it needs a limit on attempts. The site's documentation records one trade-off it accepted: someone trying the old password of an account that moved to Google is told to use Google, which does reveal that the account exists.

Password policy today

The newer edition of the US NIST SP 800-63B guidance reverses many old habits:

Do Don't
A minimum of 15 characters when the password is the only factor (8 with MFA), and allow at least 64 Require uppercase, digits and special characters
Check against lists of leaked passwords (e.g. the Have I Been Pwned API, which only receives the first 5 characters of a SHA-1 hash) Force periodic changes without signs of a leak
Allow pasting passwords (so password managers work) Security questions ("your first pet's name")
Limit attempts, lock temporarily Password hints

Complex rules make users pick Password@1, which is in every dictionary. Forced changes make them pick Password@2. Length and blocking leaked passwords work far better. This site allows passwords of 8–128 characters with no character-class rules; the minimum of 8 is below NIST's recommended 15 for passwords without MFA, something worth fixing.

But the best protection for passwords is not depending on them:

  • MFA (multi-factor authentication): a 6-digit TOTP code in an app (essentially an HMAC of the current time, post 2), a hardware key. A leaked password isn't enough to get in.
  • Passkeys (WebAuthn): the device holds a private key unique to each site and signs in by signing a challenge (post 4). The server only stores the public key, so a database leak reveals nothing. And because the signature is bound to the domain, a phishing site gets nothing.
  • Sign in with Google (post 7): hands the whole job of keeping passwords to a party with a bigger security team.

Summary

  • The right question: when the database leaks, how long do the passwords stay safe. No plain text, no encryption, no fast hashing.
  • A random salt per user: the same password gets different hashes, precomputed tables are useless, accounts must be cracked one at a time. A salt isn't secret; a pepper is.
  • Password hashing must be slow and memory-hungry: use Argon2id (or bcrypt, scrypt).
  • Measured: SHA-256 at 4.3 million hashes/second, this site's Argon2id at 44/second (23 ms), about 100,000 times slower. An 8-lowercase-letter password: 13.5 hours against 151 years on one core.
  • No hash function saves a password that's on a leaked list. Check that list at sign-up.
  • When the email isn't found, still verify against a dummy hash (0 ms → 21.8 ms, the same as a wrong password).
  • Longer beats more complex. Best of all are MFA, passkeys, or signing in with a large provider.

The next post goes into public-key cryptography: how two parties who have never met end up sharing a secret, computing RSA and Diffie-Hellman by hand with small numbers, RSA against Ed25519, and the post-quantum cryptography this site already uses.