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.

Two Adversaries, Two Sets of Controls The offline attacker holds the hash table, has unlimited attempts and specialised hardware, and is constrained by memory-hard hashing, tuned cost parameters, per-user salts and a pepper held outside the database. The online attacker uses the login form with passwords leaked elsewhere and is constrained by throttling, breach screening at set time, and a second factor. Offline attacker — holds the table unlimited attempts, no logging, no throttle purpose-built hardware, months of time Constrained only by the stored hash: memory-hard algorithm, tuned parameters, a unique salt per user, and a pepper the database leak did not include Rate limits are irrelevant to this adversary. Online attacker — has the login form passwords leaked from somewhere else two or three attempts per account Constrained by everything except the hash: per-credential and aggregate throttling, breach screening at the moment a password is set, and a second factor at login Hash cost barely inconveniences this one.

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.

Where Each Control Applies in the Credential Lifecycle Four lifecycle moments. At set time the password is screened against breach corpora and length-checked. At storage it is peppered and hashed with current parameters. At each successful login the stored hash is upgraded if the parameters or the pepper version have moved on. Rotation happens on evidence of compromise rather than on a schedule. Set time length + breach screening Storage pepper, then a memory-hard hash Each login verify, then rehash if parameters moved On evidence breach match or confirmed misuse Note what is absent: a scheduled expiry Forced rotation on a calendar degrades password quality and buys little; rotation on evidence is targeted, explicable to the user, and actually correlated with risk.

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.

Four Routes Around Everything on This Page The reset flow can restore access without the password at all. Support-assisted recovery can set a credential directly. Legacy protocol endpoints authenticate with the password but skip every added control. And the memory-hard function itself becomes a denial-of-service surface without a concurrency cap. The reset flow restores access without the password, so the mailbox becomes the real credential Support-assisted recovery an operator who can set a credential can take over any account, quietly Legacy protocol endpoints authenticate with the password and skip screening, throttling and factors The hash itself, under load a memory-hard function with no concurrency cap is a denial-of-service surface

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.