Proving Who You Are

Lesson 1 of 5

Passwords & Credential Storage

"The problem with passwords is that they are secrets you have to share to use." — a folk theorem of security, true enough to keep repeating

Part II was about breaking applications. Part III is about the mechanism every application uses to decide who someone is, and it opens with the one part of authentication that is a solved engineering problem with a specific, correct answer — which is exactly why getting it wrong is so telling.

Password storage is unusual among security topics because it has a right answer, the right answer has been known for years, and it is still done wrong constantly. The reason is a category error: password storage looks like a hashing problem, and every instinct a programmer has about hashing is exactly backwards here. You want your hashes fast — for integrity checks, for lookups, for deduplication. For passwords you want them deliberately, expensively slow, because the only thing standing between a leaked database and every plaintext password is how long each guess takes the attacker.

The numbers make the category error visceral. A general-purpose hash like SHA-256 runs at billions of guesses per second on a GPU; a purpose-built password hash at cost factor 12 runs at tens per second. That is not a small difference to tune — it is a factor of hundreds of millions, and it is the entire security of the scheme. The block below crushes a random 8-character password in a fraction of a second under SHA-256 and needs centuries under bcrypt.

Then two words people confuse and both need: salt defeats precomputation by making every hash unique, and pepper — a secret kept outside the database — means a database leak alone cannot even test a guess. Neither is optional and they do different jobs.

The big question

A database of a million SHA-256-hashed passwords leaks. The hashes are correct, the algorithm is cryptographically sound, and SHA-256 has no known break. Within hours the attacker has most of the plaintext passwords anyway. What did SHA-256 do exactly right that makes it exactly wrong for this job?

Fast is the bug

Tests — Run & Watch Them Pass

Run to execute the tests…

The fourth test is the reason a password hash lasts. Each +1 to the cost factor doubles the work, so as attackers' hardware improves you raise the cost by one and restore the original slowness — a knob that keeps pace for the system's whole life.

Everyday analogy

A bank vault is not rated by whether it can be opened — everything can be opened — but by how long it takes. A door that opens in ten seconds and a door that opens in ten hours are made of the same steel and rated completely differently, because the rating is time, and time is what lets the guards arrive. A fast hash is a vault door that opens in ten seconds; bcrypt is the same door engineered to take ten hours per attempt. The attacker still gets in eventually — but "eventually" is the entire product.

Salt and pepper do different jobs

Tests — Run & Watch Them Pass

Run to execute the tests…

The fifth test is the argument for pepper that most teams skip. With the database but not the pepper, no guess can even be tested — the attacker is missing an input, so cracking requires compromising the secret store as well, turning one breach into two.

See the difference

Try it Yourself
Loading editor…
Result

Switch between SHA-256 and bcrypt with the same password. An 8-character alphanumeric password falls in minutes under the fast hash and takes longer than a career under bcrypt — one dropdown, and it is the whole security of the scheme.

The engineer's notebook

My rule is that I never make a decision about password hashing beyond picking the library, because every part of it is a solved problem and my job is to not un-solve it. Argon2id if the platform has it, bcrypt if it does not, cost tuned so a single hash takes a few hundred milliseconds on production hardware — slow enough to hurt an attacker, fast enough that login is not annoying. The salt I never touch, because the library generates and stores it; the moment I find myself writing salt-handling code, something has gone wrong. Pepper I add as an application-level secret from the secrets manager, because it is nearly free and it means a database dump on its own is not game over. And I check the cost factor on a schedule the way I check dependencies, because a cost that was painful five years ago is comfortable now — the knob exists precisely so that raising it by one restores the original margin. The instinct I most distrust in myself and others is "I'll just SHA-256 it a few thousand times", which is reinventing PBKDF2 badly; the correct amount of custom cryptography here is none.

A little history

The lesson of this chapter was taught expensively and publicly. LinkedIn stored 6.5 million passwords as unsalted SHA-1 and lost them in 2012; because the hashes were unsalted and fast, most were cracked within days, and the breach later turned out to be over 100 million accounts. Adobe in 2013 did something arguably worse — they encrypted passwords with a reused key and no salting, so identical passwords produced identical ciphertext, and the accompanying password hints turned the dump into a crossword anyone could solve. Ashley Madison in 2015 is the instructive counter-case: they used bcrypt, and the strongly-hashed passwords largely held — except for a subset stored with a fast hash through a coding error, which fell immediately. The same breach demonstrated both the right answer and the wrong one. bcrypt itself dates to 1999, designed by Niels Provos and David Mazières specifically around the insight that a password hash should be deliberately slow and adjustably so. The knowledge was twenty years old at the time of these breaches. What was missing was not research; it was the default — which is why modern frameworks now ship a password-hashing function that is correct out of the box, and reaching for a raw hash takes deliberate effort.

In the real world

A startup I reviewed had, with the best intentions, "hardened" their password storage: SHA-256, and then — having read that iteration helps — they hashed the result 5,000 times in a loop they wrote themselves. It felt rigorous. It was roughly a thousand times faster than bcrypt at a sensible cost, because SHA-256 is built for speed and five thousand fast operations is still fast, and their hand-rolled loop had a subtle flaw that let much of the work be precomputed. They had reinvented PBKDF2 and got it wrong, which is the predictable outcome of reinventing PBKDF2. The fix was three lines: import the platform's argon2 function, set a cost, and re-hash on next login (wrapping the old hash so existing users migrated transparently). What stayed with me was their reasoning — they had assumed that because they cared more, they should build more, when the entire skill in this particular corner of security is caring enough to build nothing and use the library. The effort they spent making it worse would have been better spent on literally any other control.

Thought experiment

Find out, right now, how your application stores passwords — not what you assume, the actual function in the actual code. Then answer three questions. Is it a purpose-built password hash (argon2, bcrypt, scrypt) or a general-purpose one? What is the cost factor, and when was it last raised? And is there a pepper, or would a database leak alone be enough to start cracking? My prediction is that if the answer to the first is wrong, it is wrong in one of two ways — a raw fast hash, or a hand-rolled iteration of one — and both are the same mistake: treating a solved problem as an opportunity for cleverness. The reassuring part is that this is one of the few security fixes that is genuinely small: the correct implementation is a library call, migration can happen transparently on next login, and once done it is done. It is worth checking today precisely because it is cheap to fix and catastrophic to leave.

Common Mistakes

  • Using a general-purpose hash. SHA-256 and MD5 are fast by design, and fast is the vulnerability — the keyspace falls in seconds.

  • Rolling your own iteration. Hashing SHA-256 in a loop is reinventing PBKDF2, and usually reinventing it wrong.

  • Encrypting instead of hashing. A key that decrypts can be stolen, making storage plaintext-equivalent.

  • A longer fast hash. SHA-512 is still fast. Length is not the property that matters; deliberate slowness is.

  • No salt. One rainbow table cracks every matching password at once; a per-user salt forces separate work per user.

  • Treating salt as secret. It is not — its job is uniqueness, and it lives next to the hash.

  • No pepper. A database leak alone can then test guesses; a pepper in the secrets store makes the DB dump uncrackable by itself.

  • Never raising the cost factor. It is a Moore's-law knob — a cost painful five years ago is comfortable now.

  • Tuning the cost too low to avoid latency. A few hundred milliseconds per login is the target; imperceptible to users, expensive to attackers.

  • Building anything custom here. The correct amount of bespoke cryptography for password storage is none.

Interview Corner

Interview Corner — the same idea at three levels. Answer yours; recognise the one above it.
Warm-up
How should you store passwords?
Model answer — With a purpose-built password hash — argon2, bcrypt, or scrypt — never a general-purpose one like SHA-256 or MD5. The counterintuitive part is that everywhere else you want hashing to be fast, and here you want it deliberately slow, because the only thing protecting a leaked database is how long each guess takes the attacker. A general-purpose hash runs at billions of guesses per second on a GPU; bcrypt at a sensible cost runs at tens per second, and that factor of hundreds of millions is the entire security of the scheme. You also need a per-user salt, which the library handles, so that two people with the same password get different hashes and precomputed rainbow tables are useless — salt isn't secret, its job is uniqueness. I'd add a pepper too: an application-level secret, kept in the secrets manager and not the database, mixed into every hash, so that a database leak on its own can't even test a guess. And the cost factor is a knob you raise over time as hardware improves. The one thing I'd never do is build any of this myself — it's a solved problem and the correct amount of custom crypto is none.
Intermediate
A team hashes passwords with SHA-256 iterated 5,000 times. What's wrong and how do you fix it?
Model answer — They've reinvented PBKDF2, and probably reinvented it wrong. The instinct is right — iteration does add cost — but SHA-256 is built for speed, so five thousand fast operations is still fast, often a thousand times faster than bcrypt at a reasonable cost, which means an attacker with the leaked hashes cracks them far quicker than the team imagines. And hand-rolled iteration frequently has subtle flaws that let work be precomputed or parallelised, undoing even the cost it does add. The fix is small: use a real password hash — argon2id preferably, bcrypt if not — with a cost tuned so one hash takes a few hundred milliseconds on production hardware. Migration doesn't require a mass reset: wrap the existing hash so you can verify old passwords on next login, and transparently re-hash with the new function at that point, so users migrate as they sign in. I'd also add a pepper while I'm there since it's nearly free. The broader point I'd make is that this is a solved problem where extra effort spent being clever makes it worse, not better — the skill is recognising that and reaching for the library.
Advanced
Design password storage that survives a database breach.
Model answer — Three layers, each defeating a different attack, because "survives a breach" means the attacker has the full database and I want that to be insufficient. First, a slow tunable hash — argon2id, which is memory-hard so it resists the GPU and ASIC parallelism that makes bcrypt crackable at scale — with the cost set so a single hash is a few hundred milliseconds now and raised by one whenever hardware halves that. That makes each individual guess expensive. Second, a per-user salt, generated and stored by the library, so the attacker can't amortise work across users with a precomputed table — they must brute-force each account separately. Those two are orthogonal: the hash slows one guess, the salt stops sharing work, and you need both. Third, a pepper — a secret mixed into every hash, held in a secrets manager or an HSM, deliberately not in the database — so that a database-only leak is missing an input and can't test any candidate at all. That converts one compromise into two required compromises, which is the real meaning of defence in depth here. Operationally I'd also plan the cost-factor upgrade path from day one, because the migrate-on-login pattern only works if you've versioned the stored hashes so you know which cost each was made with. And I'd monitor for the breach itself — because the honest assumption behind all of this is that the database will leak eventually, and the design goal isn't to prevent that, it's to make the leaked data worth as little as possible for as long as possible while I rotate.
Company lens — how the same topic gets asked
micro1Direct: which hash, why slow, what salt does.
GoogleDepth: memory-hardness, cost tuning, salt vs pepper, migration.
MetaScale: cost-factor upgrades and transparent re-hashing across millions.
AmazonBreach thinking: what a DB dump alone can and cannot do.
Hands-on

Check how your application actually stores passwords — the real function, not the assumption. If it is a general-purpose hash or a hand-rolled iteration, that is the highest-value fix in this Part and it is a library call. Time a single hash at your current cost on production-like hardware; aim for a few hundred milliseconds and raise the cost factor if it is faster. Confirm the salt is per-user (the library handles this) and add a pepper from your secrets manager if there is none. Then plan the migration: wrap existing hashes so old passwords verify on next login and re-hash transparently, and version the stored hashes so future cost upgrades can do the same. Finally, run the demo and switch SHA-256 to bcrypt on the same password to see the factor you are relying on.

Tests — Run & Watch Them Pass

Run to execute the tests…

Summary

  • Password storage is a solved problem with a known-correct answer, still done wrong through a category error.
  • Fast is the bug. SHA-256 runs at ~10 billion guesses/sec; bcrypt cost 12 at ~30 — a factor of hundreds of millions.
  • That gap is the entire security. An 8-char password falls in seconds under SHA-256 and needs centuries under bcrypt.
  • Use a purpose-built hash: argon2id (memory-hard, current pick), bcrypt or scrypt.
  • The cost factor is exponential — each +1 doubles the work, so it keeps pace with hardware for the system's life.
  • Salt defeats precomputation by making every hash unique; it is not secret and lives next to the hash.
  • Pepper is a secret kept out of the database — a DB leak alone then cannot even test a guess.
  • The three layers hit three different attacks: slow hash (one guess), salt (amortisation), pepper (DB-only leak).
  • Never roll your own iteration — that is PBKDF2, done worse.
  • Never encrypt instead of hashing — a key that decrypts can be stolen.
  • Migrate transparently: verify old hashes on next login, re-hash, version the stored format for future upgrades.
  • The correct amount of custom cryptography here is none.

In the next chapter, Multi-Factor Authentication, we add a second factor — and find that not all second factors are equal, that one popular one is barely a factor at all, and that only one kind actually resists phishing.

Editorial reflection

Password storage is the clearest case in this whole volume of a problem where the right move is to do less, and I think that is genuinely hard for good engineers, which is why the mistake persists among people who care. Competence usually expresses itself as building — you understand the problem, so you construct a solution. Here, understanding the problem should express itself as restraint: recognising that the solution already exists, is battle-tested, and that your contribution is to not damage it with cleverness. The startup that iterated SHA-256 five thousand times were not lazy or ignorant; they were diligent in the wrong direction, applying effort where the correct amount of effort was a library call. I have come to think one mark of security maturity is knowing which problems are yours to solve and which are solved, because misjudging that in either direction is dangerous — building your own crypto where you should use a library, and trusting a library where you should have thought. Passwords are squarely in the "use the library" category, and the whole skill is having the humility to leave a solved problem solved.

Q&A · 0

Enrol to ask questions and join the discussion.

No questions yet — be the first to ask.