Password Storage & Credential Hygiene
Password storage is the control that decides how bad a database breach is. With a fast general-purpose hash, a leaked table is a list of plaintext passwords within hours. With a correctly tuned memory-hard function and a pepper held outside the database, the same leak yields nothing usable and the incident becomes a rotation exercise instead of a mass-takeover event.
This guide covers the whole credential lifecycle: choosing and tuning the hash, keeping a pepper in a different trust zone, screening passwords against breach corpora at set time, upgrading stored hashes transparently, and building reset and recovery flows that do not become the easiest route in. It is part of Secure Authentication & Session Architecture, and it works with the throttling described in rate limiting login endpoints — hashing decides what a leak costs, throttling decides what guessing costs.
Threat Anatomy
Two adversaries matter here, and they are defeated by different controls.
The offline attacker holds your hash table, obtained through injection, a stolen backup, a misconfigured replica or an insider. They have unlimited time, no rate limit, and hardware built for the job. Everything that constrains them is baked into the stored hash: the algorithm’s memory hardness, its cost parameters, the per-user salt, and whether a secret they do not hold was mixed in.
The online attacker has your login form and a list of passwords that real people have already used elsewhere. Hashing cost slows them slightly; what actually stops them is rate limiting, breached-password screening at set time, and a second factor. Confusing the two adversaries is why teams sometimes raise the hash cost until logins take a second while leaving the reused-password problem entirely untouched.
Prerequisites & Scope
- A maintained hashing library binding for your runtime, pinned and updated deliberately.
- A key management service or hardware module able to hold a pepper the database process cannot read directly.
- A breach corpus source — a range-query API or a locally hosted corpus — reachable at password-set time.
- A migration plan for existing hashes, since rehashing can only happen when a user next authenticates.
- Concurrency limits on the verification path, because memory-hard functions are expensive by design.
Out of scope: passwordless and federated flows, which are covered by OAuth 2.0 and OpenID Connect implementation and MFA enforcement patterns.
Mitigation Architecture
| Layer | Purpose | Failure if omitted |
|---|---|---|
| Memory-hard hash | Make offline attack expensive per candidate | Leaked table cracks in hours |
| Per-user salt | Prevent one precomputation attacking every user | A single table breaks all identical passwords at once |
| Pepper in a separate zone | Make a database-only leak unusable | Injection or a stolen backup is immediately crackable |
| Breach screening at set time | Stop known-reused passwords entering | Stuffing succeeds regardless of hash strength |
| Rehash on login | Keep parameters current without a reset campaign | Cost parameters freeze at whatever was set years ago |
| Constant-time verification path | Avoid enumerating accounts by response time | Unknown accounts answer measurably faster |
Step-by-Step Implementation
Step 1 — Hash with Argon2id and tuned parameters (ASVS V2.4.1, NIST SP 800-63B §5.1.1)
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError, InvalidHashError
# Tuned by measurement on production hardware, not copied from a blog post.
hasher = PasswordHasher(
time_cost=3, # iterations
memory_cost=64 * 1024, # 64 MiB per verification
parallelism=2,
hash_len=32,
salt_len=16, # generated per hash by the library
)
def store_password(password: str) -> str:
return hasher.hash(pepper(password)) # pepper applied first, see Step 2
def verify_password(stored: str, password: str) -> bool:
try:
hasher.verify(stored, pepper(password))
except (VerifyMismatchError, InvalidHashError):
return False
return True
Memory cost is the parameter that matters most against specialised hardware: raising it forces an attacker to provision memory per parallel guess, which is far harder to scale than raw compute. Measure at production concurrency and set the highest value you can sustain.
Step 2 — Apply a pepper the database cannot see (ASVS V2.4.5)
import hmac, hashlib
def pepper(password: str) -> str:
# PEPPER_KEY is fetched from the key management service at boot and never
# written to the database, to logs, or to a configuration file in the repo.
return hmac.new(PEPPER_KEY, password.encode(), hashlib.sha256).hexdigest()
Applying the pepper through a keyed function rather than concatenation avoids two traps: length limits in algorithms that truncate input, and the awkwardness of rotating a pepper that was simply appended. Version the key so that a rotation can be applied lazily at next login, exactly like a parameter upgrade.
Step 3 — Screen against breached passwords when they are set (ASVS V2.1.7)
Screening belongs at set time, where a rejection is a small annoyance, rather than at login, where it locks people out of accounts they still own. Use a range query so the full credential never leaves your infrastructure — the details are in breached password screening with k-anonymity.
Step 4 — Rehash transparently on successful login (ASVS V2.4.2)
def login(email: str, password: str):
user = users.find(email)
stored = user.password_hash if user else DUMMY_HASH # constant work either way
if not verify_password(stored, password) or not user:
record_failure(email)
return None
if hasher.check_needs_rehash(stored) or pepper_version(stored) < CURRENT_PEPPER_VERSION:
user.password_hash = store_password(password) # upgrade silently, in the same request
users.save(user)
return issue_session(user)
This is the only mechanism that keeps parameters current, because you never hold the plaintext at any other moment. Without it, a decision made three years ago governs your entire user base indefinitely.
Edge Cases & Bypass Patterns
The reset flow becomes the weak link. Every hardening measure on the login path is irrelevant if a reset link alone restores access and the user’s mailbox is the real credential. Reset flows need single-use, short-lived, unguessable tokens, invalidation of existing sessions, and a factor challenge where one is enrolled.
Denial of service through the hash. A memory-hard function is a resource commitment. Without a concurrency cap, a few hundred concurrent login attempts can exhaust memory. Bound the number of simultaneous verifications and shed load beyond it rather than queueing indefinitely.
Length limits and truncation. Some algorithms silently truncate long inputs, so a passphrase and its first 72 bytes hash identically. Applying the pepper as a keyed digest normalises the length before hashing, which sidesteps the problem entirely.
Support-assisted recovery. An operator who can set a password can take over any account. Require a factor from the operator, restrict the capability, write an audit entry, and notify the account owner on a channel the operator does not control.
Automated Testing & CI Validation
def test_parameters_meet_the_floor():
assert hasher.memory_cost >= 46 * 1024 # NIST-informed floor for Argon2id
assert hasher.time_cost >= 2
assert hasher.parallelism >= 1
def test_stored_hash_is_not_a_fast_digest():
h = store_password("correct horse battery staple")
assert h.startswith("$argon2id$") # catches an accidental fallback
def test_rehash_upgrades_old_parameters(db):
legacy = old_hasher.hash(pepper("hunter2"))
user = make_user(password_hash=legacy)
login(user.email, "hunter2")
assert db.reload(user).password_hash != legacy
def test_unknown_account_takes_the_same_time():
t_real = time_it(lambda: login("[email protected]", "wrong"))
t_fake = time_it(lambda: login("[email protected]", "wrong"))
assert abs(t_real - t_fake) < 0.02 # dummy hash path present
Add a pipeline check that fails on a fast hash appearing anywhere in credential code — md5, sha1, or a bare sha256 over a password field — because that regression is silent and catastrophic.
Migrating an Existing Hash Store
Almost nobody starts from a clean slate. The realistic situation is a store containing several generations of hashes — an early fast digest, a bcrypt era, and whatever the current standard is — and no ability to read any of the plaintexts. Three strategies exist, and only one of them is both safe and non-disruptive.
Upgrade on login is the default. When a user authenticates successfully you hold the plaintext for a moment, so you can rehash with current parameters and store the result. It is transparent, requires no user action, and costs one extra write on a fraction of logins. Its limitation is coverage: dormant accounts keep their old hashes indefinitely, which matters most for exactly the accounts nobody is watching.
Wrapping covers the dormant accounts immediately. Take the existing stored hash and hash that with the modern algorithm, recording the layering so verification applies both steps in order. Every stored credential gains the modern algorithm’s cost overnight without anyone logging in. The cost is a permanent record of the layering, and an eventual unwrap when the user next authenticates and you can produce a clean single-layer hash.
Forced reset is the option teams reach for and should reach for last. It converts a silent technical problem into a visible user-facing one, generates support load, trains users to expect unsolicited password-reset mail — which is itself a phishing lesson you do not want to teach — and still fails to cover accounts whose owners never respond.
In practice: wrap immediately so the weakest stored hashes are no longer the weakest link, upgrade on login so active accounts converge on a clean modern hash, and reserve forced resets for credentials with actual evidence of compromise. Record the algorithm and parameter version alongside every stored hash from the start, because a migration is far easier when the store can tell you what it contains.
Compliance Mapping
| Framework | Control | Satisfied By |
|---|---|---|
| SOC 2 | CC6.1 — logical access | Memory-hard hashing with a pepper in a separate trust zone |
| OWASP ASVS | V2.4.1 — password storage | Argon2id with measured parameters and per-user salts |
| OWASP ASVS | V2.1.7 — breached credentials | Range-query screening at password-set time |
| NIST SP 800-63B | §5.1.1 — memorized secrets | Length-based policy, breach screening, no scheduled rotation |
| ISO 27001 | A.5.17 — authentication information | Documented parameters, rotation on evidence, audited support recovery |
Common Pitfalls Checklist
Frequently Asked Questions
Is bcrypt still acceptable?
Yes, with a sensible cost factor, and it remains reasonable where a well-audited Argon2 binding is not available. Its limitation is that it is compute-hard rather than memory-hard, so specialised hardware parallelises attacks against it much more effectively. It also silently truncates input beyond 72 bytes, which catches out teams who add a pepper by concatenation. Prefer Argon2id for new systems and migrate existing hashes opportunistically at login.
What is a pepper and does it help?
A pepper is a secret applied to every password, held somewhere the database is not — a key management service or a hardware module. It helps decisively in one very common scenario: a database-only compromise via injection or a stolen backup. Without the key, the leaked hashes cannot be attacked offline at all. It does not help when the application server itself is fully compromised, which is why it complements strong hashing rather than replacing it.
Should I force periodic password changes?
No. Scheduled rotation degrades password quality — people increment a digit — and provides little measurable benefit, which is why current guidance advises against it. Rotate on evidence instead: a credential appearing in a breach corpus, a confirmed compromise, or a support-assisted recovery. Spend the policy budget on breach screening and a second factor, both of which measurably reduce takeover.
How long should the hashing take?
A few hundred milliseconds per verification on production hardware at peak concurrency — not on an idle development machine. Measure it: run the login path at your expected concurrent rate, raise the cost until latency or CPU headroom becomes uncomfortable, then step back one notch. Cap concurrent verifications as well, since an unbounded rate against a memory-hard function is a straightforward way to exhaust a server.
Related
- Secure Authentication & Session Architecture — the parent guide covering the whole authentication design
- Argon2id Parameter Tuning for Web Applications — measuring and choosing the cost parameters
- Breached Password Screening With k-Anonymity — rejecting reused credentials without disclosing them
- Password Reset Flows That Resist Account Takeover — closing the path that bypasses all of this
- MFA Enforcement Patterns — the control that makes a correct password insufficient on its own