TL;DR: Store a salted hash from a deliberately slow, memory-hard function: Argon2id first, scrypt or bcrypt where Argon2id is unavailable, PBKDF2 where FIPS compliance requires it. A fast hash such as SHA-256 lets an attacker who steals the database test billions of guesses per second on GPUs; a slow function cuts that by many orders of magnitude, and a unique salt per password stops one guess being tested against every account at once. Never encrypt passwords (reversible) and never store them in plain text.
How to approach it
Start from the threat: the database is stolen and the attacker guesses passwords offline, at their own speed. Then explain how each design choice changes their cost. Finish with concrete parameters and the operational details (upgrading old hashes, peppers).
A strong answer
Fast hash (SHA-256), no salt
Guess
from wordlists
GPU hashing
billions per second
One guess tests every account
Outcome, unsafe: Most passwords recovered
Precomputed tables work too
Argon2id with unique salts
Guess
per account
Memory-hard hash
tens of ms, lots of RAM
Salt forces per-account work
Outcome, safe: Only weak passwords fall
Pair with breached-password checks and MFA
The threat. After a breach, the attacker has every stored value and unlimited time. Their tool is offline guessing: take a candidate password, compute the stored function, compare. The goal of storage design is to make each guess as expensive as possible and to stop guesses being shared across accounts.
What each element does.
| Element | What it stops |
|---|---|
| Hashing (one-way) instead of encryption | Anyone with the key recovering every password at once |
| Unique random salt per password | Precomputed tables, and testing one guess against all accounts simultaneously |
| Slow function (work factor) | High guess rates; each guess costs the attacker real time |
| Memory-hard function (Argon2id, scrypt) | Cheap parallel cracking on GPUs and custom hardware |
| Optional pepper (secret key kept outside the database, for example in an HSM) | Cracking at all if only the database is stolen |
Concrete choices. The OWASP Password Storage Cheat Sheet recommends Argon2id with at least 19 MiB of memory, 2 iterations and 1 degree of parallelism; scrypt (N=2^17, r=8, p=1) as an alternative; bcrypt with a work factor of 10 or more for legacy systems (note bcrypt's 72-byte input limit); and PBKDF2-HMAC-SHA256 with 600,000 iterations where FIPS-140 compliance requires it. Tune parameters so a login takes acceptable server time, and revisit as hardware improves.
Why not SHA-256 with a salt. The salt prevents precomputation but not speed. SHA-256 is designed to be fast, and commodity GPUs compute billions of SHA-256 hashes per second, so common and moderately complex passwords fall quickly. A slow, memory-hard function makes each guess thousands to millions of times more expensive.
Operations. Store the algorithm and parameters with each hash so you can raise them later; when a user logs in with an old-format hash, verify it and re-hash with current parameters. Compare in constant time. Rate-limit online login attempts separately, since slow hashing protects stolen databases, not the login form.
Storing and checking a password, in code. The standard library's scrypt gives a salted, memory-hard hash; the record keeps its parameters so they can be raised later, and the comparison runs in constant time.
import hashlib
import hmac
import os
N, R, P = 2**14, 8, 1 # scrypt cost: tune to your login latency budget
def hash_password(password: str) -> str:
salt = os.urandom(16)
dk = hashlib.scrypt(password.encode(), salt=salt, n=N, r=R, p=P)
return f"scrypt${N}${R}${P}${salt.hex()}${dk.hex()}"
def verify(password: str, stored: str) -> bool:
_, n, r, p, salt, dk = stored.split("$")
test = hashlib.scrypt(password.encode(), salt=bytes.fromhex(salt),
n=int(n), r=int(r), p=int(p))
return hmac.compare_digest(test.hex(), dk)
a = hash_password("correct horse battery staple")
b = hash_password("correct horse battery staple")
print(a != b) # => True
print(verify("correct horse battery staple", a)) # => True
print(verify("Correct horse battery staple", a)) # => False
print(a.split("$")[0], len(a.split("$")[-1]) * 4, "bit key") # => scrypt 512 bit key
What interviewers probe next
- "You inherit a database of unsalted MD5 hashes. What now?" Wrap them immediately: store Argon2id(MD5(password)) for every row, then migrate to plain Argon2id at each user's next login.
- "Where should the pepper live?" Outside the database, ideally in an HSM or KMS that computes the keyed step, so a database dump alone is not enough.
- "Doesn't slow hashing let attackers DoS your login?" It costs server CPU, so rate-limit and cap request rates per account and source.
What if?
What if · interview follow-ups
Hold your answer as the constraints change
You join a team whose user table stores passwords as unsalted SHA-256 hashes, and the database backup was found on a public bucket.
Start here
Why are unsalted fast hashes so weak?
How fast can GPUs compute SHA-256?
A fast hash such as SHA-256 lets an attacker test billions of guesses per second on GPUs, and without salts identical passwords share a hash, so one guess is tested against every account at once. Common passwords fall almost immediately.
Carry it forwardFast, unsalted hashes crack quickly at scale.
Weak answerThinking SHA-256 is safe because it is cryptographic.
Constraint changes
What should passwords be hashed with?
What makes each guess expensive?
A deliberately slow, memory-hard function: Argon2id first, scrypt or bcrypt where unavailable, PBKDF2 where FIPS compliance requires it. Each guess becomes expensive, cutting attack speed by many orders of magnitude.
Carry it forwardArgon2id, scrypt or bcrypt; PBKDF2 for FIPS.
Weak answerAdding more SHA-256 rounds by hand.
Constraint changes
What does a unique salt add?
Do two users with the same password share a hash?
It makes each hash unique even for identical passwords, so an attacker must attack each account separately and precomputed tables are useless.
Carry it forwardUnique salts force per-account attacks.
Weak answerOne global salt for all users.
Constraint changes
How do you migrate existing hashes?
Can you upgrade without knowing passwords?
Wrap existing SHA-256 hashes in Argon2id immediately so all stored values are strong, then rehash with Argon2id of the plain password at each user's next login, removing the wrapper.
Carry it forwardWrap now, rehash at next login.
Weak answerWaiting for users to log in before protecting anyone.
Constraint changes
What do you do about the exposed backup?
Have the hashes already been cracked?
Treat it as a breach: remove public access, investigate access logs, force password resets, notify users and regulators as required, and check for credential stuffing against your own site. Encourage users to change reused passwords elsewhere.
Carry it forwardTreat leaked hashes as leaked passwords.
Weak answerAssuming hashes keep passwords safe.
Now defend it
What would you do?
Secure the bucket and handle the breach (resets, notification, monitoring for stuffing); wrap existing hashes in Argon2id now and rehash on next login; use unique salts and tuned Argon2id parameters going forward, with PBKDF2 only where FIPS requires; and add breached-password checks.
How it's scored
Weak: Recommends SHA-256 or encryption; omits salts.
Hire: Names Argon2id or bcrypt, explains salts and work factors, and the offline-guessing threat.
Strong Hire: Gives current parameters, covers memory hardness, peppers, upgrading legacy hashes and constant-time comparison.
Common mistakes
- Encrypting passwords "so support can recover them".
- One global salt for all users.
- Never revisiting work factors.
- Forgetting bcrypt's 72-byte input limit when combining it with long passphrases.