Storing and handling passwords safely
Open in the interactive app: practice, quiz, mock interview →In one minute
- Store passwords only as the output of a slow, salted, memory-hard hash such as Argon2id, scrypt, or bcrypt. Never encrypt them and never use a fast hash like SHA-256 alone.
- The salt makes every hash unique so precomputed tables and cross-user cracking fail; the work factor makes each guess expensive.
- The work factor is a dial between login latency and attacker cost. Tune it to your hardware and raise it over time, rehashing on successful login.
- Modern policy favours length, breached-password screening, and rate limiting over composition rules and forced periodic rotation.
- The password store is only one layer. Credential stuffing, phishing, and reset flows cause more account takeovers than hash cracking.
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 --> SWorked 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
- Meta, 2019: plaintext passwords in internal logs. A routine review in January 2019 found that some user passwords had been written in readable form to internal storage, despite a login path designed to mask them: hundreds of millions of Facebook Lite users, tens of millions of other Facebook users and, after an April update, millions of Instagram users. Perfect hashing in the database did not help, because the leak happened before the hash. Meta fixed the logging and reviewed how other secrets such as access tokens were stored (Meta).
- Okta, 2024: bcrypt's 72-byte limit skipped the password check. From 23 July to 30 October 2024, Okta's AD/LDAP delegated authentication built a cache key by bcrypt-hashing
userId + username + password. With a username of 52 characters or more, a user with a cached login and no MFA could be authenticated from cache while the AD agent was unreachable without the right password. Okta replaced bcrypt with PBKDF2 for the cache key (Okta advisory). - Snowflake customers, 2024: stolen passwords, no MFA, about 165 organisations. Mandiant tracked UNC5537 logging in to customer instances with credentials harvested by infostealer malware, some from as early as 2020 and never rotated. At least 79.7% of the accounts used had prior exposure, and none had MFA or network allow lists. No hash was cracked: the passwords were simply valid (Google Threat Intelligence).
Trade-offs
| Option | Strengths | Weaknesses | Choose when |
|---|---|---|---|
| Argon2id | Memory-hard, tunable, current recommendation | Memory cost per login, newer library support | Default for new systems |
| scrypt | Memory-hard, widely available | Parameters harder to tune | Argon2 unavailable |
| bcrypt | Battle-tested, everywhere | 72-byte input limit, not memory-hard | Legacy stacks, constrained platforms |
| PBKDF2 | FIPS-approved, ubiquitous | Not memory-hard, needs very high iterations | Compliance requires FIPS |
| Passkeys (no password) | Phishing-resistant, nothing to crack | Recovery flows, device coverage | Where users and platforms support it |
Pitfalls in production
- Fast hashes (MD5, SHA-256) even with salt crack quickly on GPUs. Migrate by wrapping legacy hashes and rehashing on login.
- Logging request bodies captures plaintext passwords. Redact credentials at the framework level and test it.
- Login errors that differ for unknown users enable enumeration. Use one message and similar timing.
- Unbounded password length lets attackers send megabyte inputs to burn CPU. Cap input length, for example 1,024 bytes. For bcrypt's 72-byte limit, either enforce 72 bytes or pre-hash carefully: raw digest bytes can contain a NUL that truncates input, and an unkeyed pre-hash enables shucking, so OWASP suggests base64 of HMAC-SHA-384 keyed with a pepper.
- Feeding bcrypt anything other than the password. Concatenating a user id and username in front of the password can push the password past byte 72, where bcrypt ignores it (see Okta under In the wild).
- Reset tokens stored in plaintext or valid for days are as good as passwords. Store them hashed, expire within an hour, single use.
Leadership lens
- Benchmark the hash on production hardware and set parameters to a latency target, such as 100 to 250 ms.
- Treat authentication as its own service with capacity planning; hash cost is real compute.
- Track account takeover sources. Usually stuffing and phishing dominate, so invest in MFA and bot defence, not only stronger hashing.
Check yourself
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.
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.
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.
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
- Provos and Mazières, A Future-Adaptable Password Scheme (USENIX 1999): the bcrypt paper and the idea of a tunable cost stored with the hash.
- RFC 7914: The scrypt Password-Based Key Derivation Function: the first widely used memory-hard design and its N, r, p parameters.
- RFC 8018: PKCS #5 v2.1: the PBKDF2 specification still required in FIPS environments.
Systems docs
- Django: Password management: a production framework's hasher list, automatic upgrade on login and wrapped legacy hashers.
- Spring Security: Password storage: the
{id}prefix format that lets one store hold several algorithms during a migration. - Have I Been Pwned: API v3: the Pwned Passwords range API used for breached-password screening.
- Okta: AD/LDAP delegated authentication advisory: a short, real example of the bcrypt truncation pitfall.
Books
- Building Secure and Reliable Systems, Adkins et al., chapter 1 "The Intersection of Security and Reliability": opens with "On Passwords and Power Drills", the outage of Google's own internal password manager. Free online.
- Computer Networking: A Top-Down Approach (8th edition), Kurose and Ross, chapter 8 "Security in Computer Networks": hashing, integrity and authentication protocols underneath password login.
Other curricula
- roadmap.sh backend roadmap: lists bcrypt and scrypt as hashing algorithms to know, without parameters or migration.
- donnemartin/system-design-primer: has no section on password storage; its security notes stop at encryption and least privilege.
Watch
- How NOT to Store Passwords! (Computerphile): why plaintext, encryption, and fast hashes fail.
- System Design: How to store passwords in the database? (ByteByteGo): salting and slow hashing in a few minutes.
Free resources
- OWASP Password Storage Cheat Sheet: recommended algorithms and parameters, peppering, and legacy upgrades.
- NIST SP 800-63B (rev. 4): current guidance on password length, screening, and rotation.
- RFC 9106: Argon2: the Argon2 specification with recommended parameter sets.
- Validating Leaked Passwords with k-Anonymity (Cloudflare): how to check breached passwords without revealing them.