Engineering Atlas › System Design Fundamentals

Storing and handling passwords safely

12 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

How it works

Why hashing, not encryption

Encryption is reversible by whoever holds the key. A password check only needs to compare, so store a one-way function. If the database leaks, the attacker must guess passwords one at a time.

Why slow

A modern GPU computes billions of SHA-256 hashes per second. An eight-character lowercase password has about 200 billion possibilities, cracked in seconds. A password hash is deliberately expensive: bcrypt with cost 12 takes around 250 ms on a server core, and Argon2id also demands memory (for example 64 MB per hash), which defeats the massive parallelism of GPUs and ASICs.

Salt and pepper

A salt is 16 random bytes generated per password and stored next to the hash; the Argon2 RFC calls 128 bits sufficient for all applications (RFC 9106), and NIST's floor is 32 bits. Two users with the same password get different hashes, and an attacker cannot crack all users at once. A pepper is an extra secret held outside the database, for example in a KMS or HSM, and mixed in by HMAC before or after hashing. If only the database leaks, hashes cannot be attacked at all without the pepper. The catch is rotation: a pepper cannot be removed from a stored hash without the plaintext, so replacing a leaked pepper means every affected user must reset (OWASP Password Storage). Plan a version id per pepper from day one.

Encoded format and upgrades

Store algorithm, parameters, salt, and hash together, as in $argon2id$v=19$m=65536,t=3,p=1$salt$hash. On login, after a successful verify, compare parameters with current policy and rehash if they are weaker. Django does exactly this by default, upgrading both the algorithm and the work factor on login, and warns never to remove an old hasher from the list or those users can no longer log in (Django docs). Users who never log in can be upgraded by wrapping their old hash: store argon2(old_md5_hash) and verify by computing the inner hash first. Know the residual risk: if the same users' raw MD5 hashes leak from an old backup or another site, an attacker can crack the fast inner hash and skip Argon2 entirely, a trick called password shucking. Mixing a pepper into the wrap, and forcing a reset for long-dormant accounts, closes it.

Verification

Compare hashes in constant time so response timing does not reveal how many bytes matched. Return the same error and similar timing for "unknown user" and "wrong password", to avoid user enumeration.

Policy

Require a minimum length (NIST now says 15 when the password is the only factor, 8 with MFA), allow at least 64 characters, screen against breached-password lists, and drop composition rules and scheduled expiry. The final SP 800-63B-4 (July 2025) turns several of these into hard rules: composition rules and periodic changes are prohibited, so are password hints and security questions, the whole password must be verified with no truncation, password managers and autofill must be allowed, and failed attempts must be capped at 100 in a row per account (NIST SP 800-63B-4, CSRC record).

Down the stack

Layer 1The contract

A password hash promises that verification succeeds only for the original password, apart from a negligible collision chance, and that the stored record gives an attacker nothing better than guessing, at a fixed cost per guess. The verifier promises that its running time does not depend on how many characters of a guess were right. The encoded string promises enough metadata (algorithm, parameters, salt) to verify the record years later, even after the defaults have changed.

Layer 2The mechanism

bcrypt runs Blowfish's expensive key setup 2^cost times, alternating the salt and password into the key schedule, then encrypts the constant string "OrpheanBeholderScryDoubt" 64 times. Its output looks like $2b$12$ followed by a 22-character salt and a 31-character hash in a custom base-64 alphabet, and it only reads the first 72 bytes of input. Argon2id fills m KiB of memory with 1 KiB blocks, each computed by a BLAKE2b-based compression function from the previous block and a pseudo-randomly chosen earlier block. The first half of the first pass picks reference blocks independently of the password, which resists side channels; the rest picks them from the data, which resists time-memory trade-offs. It makes t passes over memory in p parallel lanes. PBKDF2 simply iterates HMAC; OWASP currently suggests 600,000 iterations of HMAC-SHA-256. For Argon2id, OWASP's floor is 19 MiB, two passes and one lane, while RFC 9106 recommends 64 MiB, three passes and four lanes when 2 GiB per hash is not affordable (OWASP Password Storage, RFC 9106).

Layer 3The machine

A single high-end GPU computes on the order of 20 billion SHA-256 hashes per second, and about a thousand bcrypt hashes per second at cost 12. A stock RTX 4090 benchmarks at about 22 billion SHA-256 per second and 184,000 bcrypt per second at cost 5; each extra cost step halves that, so cost 12 lands near 1,400 (hashcat benchmark). Memory changes the picture: a GPU with 24 GB can only hold about 375 concurrent Argon2id instances at 64 MiB each, far fewer than its thousands of cores. Each such hash with three passes moves roughly 400 MB through memory, so at 1 TB/s of bandwidth the card manages only a few thousand guesses per second. On the server, each login allocates 64 MiB, which the kernel backs with zeroed pages on first touch, so hashing also costs page faults and memory bandwidth. Comparing hashes with an ordinary string compare returns at the first differing byte; that difference is a few nanoseconds, but over many requests on a fast network it can be measured.

Why it's built this way

Attackers have massively parallel hardware while defenders compute one hash per login, so the hash must be expensive in the resource attackers scale worst, which is memory. Precomputed tables amortise work across victims, so every record gets its own salt. Hardware keeps getting faster, so the cost is a parameter stored with each hash and raised over time.

Diagram

flowchart TD
  L[Login request] --> RL{Rate limit OK?}
  RL -->|no| D[Reject, backoff]
  RL -->|yes| F[Load user record]
  F --> P["Parse algo, params, salt"]
  P --> H["Hash password + salt (+ pepper)"]
  H --> C{Constant-time equal?}
  C -->|no| E[Generic error]
  C -->|yes| U{Params below policy?}
  U -->|yes| R[Rehash and store]
  U -->|no| S[Create session]
  R --> S

Worked example

A consumer app has 50 million users and peaks at 2,000 logins per second.

With Argon2id at 64 MB memory and 3 iterations, one hash costs about 100 ms of CPU and 64 MB of RAM for its duration. At 2,000 logins per second, that is 200 CPU cores busy and 2,000 x 0.1 s = 200 concurrent hashes, using 12.8 GB of RAM. Dedicated auth hosts with 32 cores each mean about 7 hosts at full load, so 10 to 12 for headroom.

Attacker side: if the database leaks, a GPU that does 20 billion SHA-256 guesses per second might manage a few thousand Argon2id guesses per second at these parameters. Testing a 100-million-word list against one user takes about 8 hours; against all 50 million users, with unique salts, roughly 45,000 years. Weak passwords still fall, which is why breached-password screening at signup matters.

Upgrading from unsalted SHA-1: wrap every hash as argon2id(sha1) on day one, then rehash to plain Argon2id as users log in.

In the wild

Trade-offs

OptionStrengthsWeaknessesChoose when
Argon2idMemory-hard, tunable, current recommendationMemory cost per login, newer library supportDefault for new systems
scryptMemory-hard, widely availableParameters harder to tuneArgon2 unavailable
bcryptBattle-tested, everywhere72-byte input limit, not memory-hardLegacy stacks, constrained platforms
PBKDF2FIPS-approved, ubiquitousNot memory-hard, needs very high iterationsCompliance requires FIPS
Passkeys (no password)Phishing-resistant, nothing to crackRecovery flows, device coverageWhere users and platforms support it

Pitfalls in production

Leadership lens

Check yourself

  1. Why does a unique salt not slow down cracking a single targeted password?

    Show answer

    The attacker still hashes guesses one by one for that user; salt only stops precomputed tables and attacking many users with each guess. Slowness comes from the work factor.

  2. Why is memory hardness useful against attackers but a cost for defenders?

    Show answer

    GPUs and ASICs have little fast memory per core, so requiring 64 MB per hash cuts their parallelism. The defender pays that memory for every concurrent login.

  3. How can you upgrade users with legacy MD5 hashes who never log in?

    Show answer

    Wrap the stored MD5 output in the new algorithm now, and verify by computing MD5 then the new hash; replace with a direct hash at next login.

  4. Why does modern guidance drop forced periodic password changes?

    Show answer

    Users respond with predictable variations, which weakens passwords. Changing on evidence of compromise is more effective.

Sources and further study

Primary

Systems docs

Books

Other curricula

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →