Storing a Password
14.0 What this chapter gives you#
- You will be able to explain why storing a password in readable form, and storing it in a form you can decrypt, are the same mistake wearing two different hats.
- You will be able to state the preimage property of a one-way function exactly, and say why that property alone does not protect a password.
- You will be able to quote measured hardware figures for how fast a general-purpose hash can be computed on a graphics card, and turn them into a cracking rate.
- You will be able to say precisely what a salt defeats, precisely what it does not, and how long it must be and why.
- You will be able to describe a pepper, name the one failure it defends against, and price the operational burden it brings with it.
- You will be able to compare PBKDF2, bcrypt, scrypt and Argon2id on the axes that matter, and pick one for a given constraint.
- You will be able to tune cost parameters against a latency budget, using a measurement procedure rather than a copied number.
- You will be able to design an upgrade path that moves every stored record to a stronger scheme without asking a single user to reset anything.
- You will be able to read and write a complete storage record: algorithm identifier, version, parameters, salt and digest, in the format the tools actually emit.
- You will be able to state what NIST SP 800-63B-4 of 31 July 2025 requires of a password verifier, and where the requirement and the best available practice part company.
There is a small, strange problem at the centre of every login screen ever built, and almost nobody states it out loud. A service has to be able to tell whether the password you just typed is the right one, and to do that it appears to need to know the right one. But if it knows the right one, anyone who takes a copy of its records knows it too, for you and for everybody else at once. This chapter is the resolution of that apparent contradiction: how to check a secret you are not permitted to hold.
The resolution has been known since about 1970, and it is one of the genuinely beautiful ideas in computing. You do not store the password. You store the result of putting the password through a calculation that cannot be run backwards. To check a login you run the same calculation on what the user typed and compare the two results. The service can recognize the password without ever holding it, in exactly the way you can recognize a friend’s handwriting without being able to produce it.
That idea, on its own, is not enough, and everything after it is a patch on a specific weakness in the layer below. The calculation can be run forwards very fast, so the attacker guesses; so we make it slow. Making it slow does not stop the attacker computing one answer and comparing it against every account at once, so we make every account’s calculation different. That still leaves the attacker with everything he needs the moment he steals the database, so some organizations move one ingredient out of the database entirely. Each of those layers has a name, a cost, a specification behind it, and a precise statement of what it does not do. That precision is the point of this chapter, because a defence you have misunderstood is worse than no defence: it makes you stop looking.
The chapter before this one was about the password itself, where it came from and how people choose it. The chapter after this one is about how stored passwords are attacked, with cracking rates and attack strategies, and you should read it before making a final decision about parameters. Here we are concerned with the storage side alone: the record in the database, what goes into it, and every reason each field is there.
The plain version#
The paint shop that must recognize a colour it is not allowed to write down#
Imagine a paint shop that mixes custom colours. Thousands of customers come in, each with a private recipe: so many drops of red, so many of blue, a measure of yellow, a particular white base. The recipe is the customer’s own, and the shop has promised, in writing, never to keep it.
The shop still has a job to do. When you come back next year and say “the same as last time”, it must be able to tell whether the recipe you have just recited is really the one you used before, or whether somebody has walked in off the street pretending to be you.
Here is how it solves that. When you first give your recipe, the shop mixes one small tin, lets it dry, and cuts a chip of the dried paint about the size of a stamp. The chip goes in an envelope with your name on it, and your recipe is destroyed. Next year, when you recite a recipe, the shop mixes a fresh tin from what you said, dries it, and holds the new chip against the old one under a standard lamp. If the two are the same colour, you are you.
Look at what the shop now holds. Not your recipe. A colour. And here is the property everything depends on: mixing is easy and unmixing is not. Anyone can take a recipe and produce the colour in five minutes. Nobody can take a dried chip of grey-green and read off the drops that made it. That is what we mean when a calculation is one-way: easy to run forwards, not runnable backwards.
The industry word for the chip is a digest, and the word for the mixing machine is a hash function. We will use the plain words a little longer.
Why the envelope is still worth stealing#
Suppose a thief takes the whole drawer of envelopes. He has twelve thousand names and twelve thousand colour chips, and no recipes at all. Has he got anything?
He has, and this is where most explanations stop too early. The thief cannot unmix the chips, but he can mix. He sits down with the machine and starts guessing: the obvious recipes first, three drops of red and nothing else, the recipe printed on the back of the manual, the one every beginner uses. He mixes each guess, dries it, and holds it against all twelve thousand chips at once. Any chip that matches, he has broken.
Notice the shape of that attack, because it is the whole game. The thief is not reversing anything. He is running the machine forwards, over and over, on guesses. His work is not “undo the calculation”, which is impossible; it is “search the list of things people actually choose”, which is very possible indeed, because most people do not invent recipes. They use one of a few thousand well-known ones.
So the shop’s defence is not really “the chip cannot be unmixed”. It is “guessing is expensive”, which turns the whole thing into an economics problem: what does a guess cost the thief, and how many does he need.
Making the machine slow on purpose#
The shop can attack the cost per guess directly, and the way it does so is counter-intuitive. It buys a worse machine.
Not a broken one: a deliberately slow one. Instead of mixing a tin in a hundredth of a second, the new machine stirs for a third of a second, in a great deal of solvent, in a drum you cannot make smaller. For a customer at the counter, a third of a second is nothing; they are still putting their card away. For a thief who needs a hundred million recipes it is a year of continuous mixing, and he cannot escape it by buying a thousand tiny mixers, because the recipe will not mix in a small drum. He would need a thousand full-size drums, and drums cost money and take up floor space.
That is the single most important idea in this chapter. The defender pays the slowness once per login. The attacker pays it once per guess. The ratio between those two numbers is the whole of the protection, and you can turn the dial.
The drum matters as much as the stirring time, for a reason that is not obvious. Machines that need only time can be copied cheaply and run in parallel by the thousand. Machines that need a large volume of solvent cannot, because solvent and tank space stay expensive however clever your engineering. Requiring room is a better defence than requiring time, because room is what the attacker finds hardest to buy in bulk.
Why two customers must never get the same chip#
Now a second problem, which the slow machine does not solve.
Two customers, who have never met, both use the shop’s suggested starter recipe from the leaflet. They give the same recipe, so they get the same colour, so their envelopes contain identical chips. The thief who steals the drawer notices at once. He sorts the chips by colour and finds four hundred envelopes containing the same shade of blue. He now knows, without any work at all, that four hundred customers share a popular recipe, and one successful guess opens all four hundred envelopes.
Worse, a patient criminal can prepare in advance. Long before any theft he can mix every recipe in the popular recipe book, dry them, and file the chips in a reference book of his own, sorted by colour, with the recipe written on the back. When a drawer of envelopes falls into his hands he mixes nothing; he looks each chip up. Years of work, done once, reused against every shop in the country.
The shop’s answer is a small, elegant trick. Before mixing any customer’s tin it adds one measure of a starter tint drawn from a jar of thousands of slightly different tints, chosen at random, different for every customer. The tint is not secret; the shop writes down which one it used, on the envelope, next to the chip. Its only job is to be different.
Now the two customers with the same recipe get different chips. The sorting trick fails. The reference book fails too, because the criminal would need a separate book for every possible starter tint, and there are far too many. And the thief who wants to test one guessed recipe against twelve thousand envelopes must now mix it twelve thousand times, once with each customer’s tint, instead of once for all of them. His work has gone up by a factor of twelve thousand.
The industry word for the starter tint is a salt, and it is the oldest of these defences: it was published in 1979, in a paper by Robert Morris and Ken Thompson about the passwords on the Unix system at Bell Labs.
Be careful what you take from this. The tint does not make anyone’s recipe better. If a customer’s recipe is “three drops of red”, a thief who targets that one customer will still find it in seconds. The tint removes the attacker’s bulk discount. It does nothing about the price of a single item.
The ingredient kept in the manager’s safe#
There is one more layer, and it is the one easiest to get wrong.
Everything so far lives in the same drawer. The chips are there, the tints are written on the envelopes, and the mixing instructions are printed on the wall for anyone to read. A thief who takes the drawer has everything he needs to start guessing that night.
So some shops keep one ingredient out of the drawer: a single measure of a special base, the same for every customer, kept in the manager’s safe and never written on any envelope. Every tin gets it, and without it no guess can produce the right colour however correct the recipe. A thief who takes the drawer and not the safe is stuck; every chip he mixes comes out wrong.
This is a real gain and it is narrow. It helps only in the exact case where the drawer is taken and the safe is not, which does happen, because databases leak through back-up tapes and misconfigured servers far more often than locked hardware does. It helps not at all if the intruder has the run of the place, because then he takes both.
And it costs. If the special base is lost, every chip becomes unverifiable and every customer must start again from nothing, so the safe must be as reliable as the drawer and open every time anyone comes to the counter. Changing the base is not a small job either, because every chip in every envelope was mixed with the old one. The industry word for this ingredient is a pepper, and the honest summary is that it is a good idea a small organization will usually implement badly.
Meera’s tin, carried through the chapter#
Let us fix one example now and follow it to the last page.
Meera lives in Pune and has an account with a grocery delivery service that has eight million registered users. Her password is Pihu@2019: her daughter’s name, the symbol the site insisted on, and the year she signed up. It is not in any list of common passwords, and every strength meter she has seen calls it good.
We will store that password five ways, in increasing order of sense, saying at each step what a thief who steals the database can do. Stored as it is, he reads it. Stored in a locked-up form the service itself can unlock, he reads it as soon as he finds the key, and the key is on the same servers because the service needs it daily. Stored as a chip with no starter tint, he adds Meera’s chip to eight million others, sorts them, and works through popular recipes in bulk; Pihu@2019 is unusual enough that it may survive, but a third of the database will not. Stored as a chip with a tint but a fast machine, the sorting trick and the bulk discount both die, and he can still make tens of billions of guesses a day against Meera alone. Stored as a chip with a tint on a deliberately slow, large-drum machine, he gets a few thousand guesses per second for a whole graphics card, and Pihu@2019 may still fall, because a name, a symbol and a year is exactly the shape a good attacker guesses on purpose.
That is the honest ending. Storage done properly does not make a mediocre password safe. It makes the difference between losing every account in the database and losing the weakest few per cent, which is an enormous difference, and it is the only part of the problem the service controls.
Where the plain version stops being true#
The thief does not unmix, and “cannot be reversed” is not a proved fact#
The paint analogy says unmixing is impossible. Physically that is almost true: separating dried paint into its original pigments is beyond any laboratory. Mathematically, the corresponding claim about hash functions is weaker than it sounds.
The honest version: nobody has proved that any real hash function is hard to invert. What we have is a function with a published design, examined by a large number of people for a long time, against which the best known attack is no better than trying every input. That is a statement about the current state of public knowledge, not a theorem. SHA-1, published by the American standards body in 1995, was considered adequate for years and was broken for collisions by a Google and CWI Amsterdam team in February 2017. MD5, from 1992, fell far earlier. The design of a one-way function is an empirical science, and the correct posture is to assume today’s functions will one day be weakened and to build systems that can change algorithms.
Guessing is the attack, and the shape of the guesses is not random#
The analogy shows a thief working through a recipe book. Real guessing is far more sophisticated, and the difference matters when you estimate how long your users’ passwords will hold.
A real attacker does not enumerate all possible strings. He starts with password lists from previous breaches, applies transformation rules that mimic what people do to make a password satisfy a policy, and trains statistical models on the millions of real passwords he already holds. A password of the form name, symbol, four-digit year is one of the most heavily targeted shapes there is, precisely because composition rules produced millions of them. The whole apparatus is the subject of the next chapter; here it is enough to know that the attacker’s guess list is not a dictionary, it is a model of human behaviour.
Slow is measured on the attacker’s machine, not yours#
The analogy says the shop buys a slow machine and both sides suffer equally. They do not.
Your login server is a general-purpose computer running an operating system, a web framework, a database driver and your code, doing one password at a time because a person clicked a button. The attacker’s machine is a rack of graphics cards doing nothing else, running a hand-optimized kernel with no operating system in the loop, hashing tens of thousands of candidates in parallel. For an old-fashioned fast hash the ratio between those two situations is several thousand to one in the attacker’s favour.
That asymmetry is why the modern answer is not “iterate a fast hash more times” but “use a function a graphics card is bad at”. The large drum in the analogy is the real mechanism: memory. A graphics card has thousands of arithmetic units and comparatively little memory per unit, so a function that insists on a large working area per guess strips away most of the attacker’s advantage. That was a deliberate design decision, first in scrypt in 2009 and then in Argon2 in 2015, and it is the largest improvement in password storage since salt.
The starter tint is not a secret, and treating it as one causes real bugs#
In the analogy the tint is written on the envelope, and that is correct. Beginners find this uncomfortable and try to hide the salt, encrypt it, or derive it from the username so it does not have to be stored. Every one of those is a mistake and two of them are dangerous.
A salt derived from the username is the same for that username at every site in the world, which restores exactly the precomputation attack salt exists to prevent. A salt that is the same for all users in one database restores the bulk discount within that database. A salt kept secret is a pepper, which is a different thing with different properties, and calling it a salt means it will be handled with the care given to a salt, which is none.
The honest version: a salt has exactly one job, to be unique. It is not a secret, it does not need to be unpredictable in the way a key does, and it is stored in the clear next to the digest it belongs to.
The pepper defends one scenario and complicates six#
The analogy makes the manager’s safe sound like an obvious upgrade. In practice it is the layer most often built wrong, or built and then quietly abandoned.
The scenario it defends is narrow but real: the database contents leak while the key material does not. It does not defend the most serious intrusion, in which the attacker has code execution on the application servers and can simply ask the key holder to do the work for him, or read the key from memory.
And it introduces failure modes that did not exist. If the key store is unreachable, nobody in the world can log in. If the key is lost, every password in the database is permanently unverifiable. If you ever want to change the key, the way you built the pepper determines whether that takes an afternoon or is impossible, and most designs make it impossible. The technical version sets out which construction can be rotated and which cannot, because that single choice is the difference between a pepper you can live with and one you cannot.
There is no storage scheme that makes a password safe#
The last and most important place the plain version misleads.
Everything in this chapter raises the cost of guessing. Nothing in this chapter stops guessing. If the password is one of the ten thousand most common, no algorithm, no parameter and no key store will save it, because the attacker will reach it within the first few seconds of any campaign, at any cost setting you can afford to run at your own front door.
The honest framing is the one the industry converged on slowly: password storage is a way of buying time and limiting blast radius. It converts “every account is lost the moment the database leaks” into “the weakest accounts are lost, over weeks, and we have time to force resets”. It does not convert a stolen database into a non-event. It never has.
The technical version#
The four ways passwords are stored, ranked by how bad they are#
Every scheme in production anywhere falls into one of five categories. The first four are defects. Only the fifth is a design.
The first is plaintext: the password written into a column as typed. Anyone with read access to that column, which in most organizations means a large number of people and every backup, has every password. The RockYou breach of December 2009 is the standard example: 32,603,388 passwords published in readable form because they had been stored in readable form, and that file is still, sixteen years later, the first thing loaded into every password-cracking tool in the world.
The second is obfuscation: base64, a fixed substitution, a home-made scramble. This is plaintext with an extra step. The third is reversible encryption, which has its own subsection below because it is the one that sounds responsible.
The fourth is an unsalted general-purpose hash, MD5 or SHA-1 or SHA-256 applied once: the mistake made by competent engineers who understood the one-way idea and stopped there. LinkedIn stored passwords this way, and its 2012 intrusion produced a file of 6.5 million SHA-1 hashes that appeared publicly in June 2012, followed in May 2016 by a much larger set covering around 117 million accounts.
The fifth is a purpose-built password hashing function with a unique salt and tuned cost parameters. That is the only category that is a design rather than an accident, and the rest of this chapter is about it.
| Storage form | What a leak costs | Verdict |
|---|---|---|
| Plaintext | Every password, at once | Indefensible |
| Obfuscated | Every password, at once | Indefensible |
| Reversible encryption | All, once key is found | Indefensible |
| Unsalted fast hash | Most, within days | Negligent |
| Salted fast hash | Weak ones, within days | Obsolete |
| Tuned password KDF | Weakest few per cent | Correct |
Reversible encryption, and why “we encrypt passwords” is a confession#
When an organization announces after a breach that passwords were encrypted rather than hashed, the sentence is usually intended to reassure and should be read as an admission.
Encryption is by definition reversible: it exists so that somebody with the key can recover the original. A password store built on encryption therefore contains, somewhere in the same system, the means to recover every password. The key must be available to the login service continuously, so it lives on the application servers or in a store those servers reach without human intervention, which is to say inside the same blast radius as the database. An attacker deep enough to take the database is very often deep enough to take the key.
The canonical case is Adobe. In October 2013 Adobe disclosed an intrusion, and the file that circulated afterwards covered about 153 million accounts. The passwords were not hashed. They were encrypted with Triple DES in ECB mode, and three properties of that choice turned a bad breach into a teaching example still used thirteen years later.
First, a single key for the whole database meant recovering it would decrypt everything at once. Second, ECB mode is deterministic and blockwise: the same input block always produces the same output block, with no per-record variation, so identical passwords produced identical ciphertext and the file could be sorted by ciphertext to find the most popular passwords, exactly as with an unsalted hash. Third, and this is the part that made it notorious, the same records carried the users’ own password hints in readable form. Given a group of accounts sharing a ciphertext and a few dozen hints attached to that group, the password could often be read straight off the hints without touching the cryptography at all.
The general rule is worth stating plainly. If your system can tell a user what their password was, your system is storing passwords, whatever the marketing copy says. A password store must be a one-way store, and the only legitimate operation on it is comparison. Encrypting an already one-way digest as an outer layer is a different matter entirely, and a legitimate one; that is the pepper construction covered below.
The one-way function and the preimage property, stated precisely#
A cryptographic hash function H maps an input of any length to a fixed-length output of n bits; for SHA-256, n is 256. Three properties are usually claimed for such a function, and only one is relevant here.
Preimage resistance. Given a value y that is the output of H on some unknown input, it should be computationally infeasible to find any input x such that H(x) equals y. For an ideal function with n-bit output the best generic attack is to try inputs until one matches, costing about 2 to the power n operations. This is the property password storage relies on.
Second-preimage resistance. Given a specific input x, it should be infeasible to find a different input x’ with H(x’) equal to H(x). Generic cost is also about 2 to the power n.
Collision resistance. It should be infeasible to find any pair x and x’ with the same output. Because of the birthday effect the generic cost is only about 2 to the power n divided by 2, so 2 to the power 128 for SHA-256.
Collision resistance is what matters for digital signatures and certificate issuance, and it is the property that fell for MD5 and SHA-1. It has almost nothing to do with password storage: a collision would let an attacker log in with a different string that hashes the same, which is a curiosity, since he already wants a preimage and a preimage is a password.
Now the crucial observation, which a great many engineers have got wrong. The 2-to-the-power-256 figure describes the difficulty of inverting SHA-256 on an input drawn from the whole space of possible inputs. Passwords are not. They come from a distribution concentrated on a few billion strings that human beings actually type, and the attacker knows that distribution intimately, because he holds tens of billions of samples from previous breaches.
Preimage resistance says the attacker cannot go backwards. It says nothing about him going forwards through a short list. The security of a password hash is therefore not the security parameter of the hash function; it is the cost of one evaluation multiplied by the number of guesses needed, and the number of guesses is a property of the user, not the algorithm. This single point is why the rest of the chapter exists.
Why a fast hash is the wrong tool, with measured throughput#
SHA-256 was designed to be fast. That is not a flaw; it is a requirement. It hashes firmware images, verifies downloads, underpins the integrity of transport security and signs certificates, and in every one of those roles speed is a feature. It is the wrong tool for passwords for exactly the reason it is the right tool for everything else.
The measurements below are published benchmark runs of the hashcat tool on single consumer graphics cards: the 4090 figures from a benchmark published on 14 October 2022 using hashcat v6.2.6, the 5090 figures from one published on 10 February 2025 using v6.2.6-851. Both cards are ordinary retail hardware.
| Algorithm | RTX 4090 | RTX 5090 |
|---|---|---|
| MD5 | 164.1 GH/s | 220.6 GH/s |
| SHA-1 | 50,638.7 MH/s | 70,245.1 MH/s |
| SHA-256 | 21,975.5 MH/s | 28,353.3 MH/s |
| SHA-512 | not listed here | 10,048.6 MH/s |
| bcrypt, cost 5 | 184.0 kH/s | 304.8 kH/s |
Read the SHA-256 line carefully, because it is the number this section exists to deliver. A single retail graphics card computes about 21,975,500,000 SHA-256 hashes per second, and the newer card about 28,353,300,000. Per day, one RTX 5090 performs roughly 2.45 quadrillion SHA-256 evaluations.
Now apply that to Meera. Her password Pihu@2019 has the shape: a capitalized four-letter name, one symbol, four digits. Suppose an attacker attacks that shape directly rather than guessing blindly, with a list of the twenty thousand most common Indian given names, the twenty most common symbols, and years from 1950 to 2026. That is 20,000 times 20 times 77, which is 30,800,000 candidates. On an unsalted SHA-256 store, one RTX 5090 exhausts that entire space in about 0.0011 seconds. Not a day, not an hour: about one millisecond.
That is the whole argument against fast hashes, and no clever engineering elsewhere in your stack repairs it. If your storage is a single fast hash, an attacker with a stolen database and a gaming computer has every password following any recognizable human pattern before he has finished his coffee.
The same table gives the contrast. The bcrypt line is measured at cost 5, which is 32 iterations of the expensive inner routine and far below any sane production setting; hashcat benchmarks at cost 5 because a benchmark has to finish. At cost 12, which is 4,096 iterations, the rate is 2 to the power 7 times lower, so the RTX 5090 figure of 304,800 per second becomes about 2,381 per second. From 28 billion to two thousand is a factor of roughly 11.9 million, on the same card, for the same money.
The strongest figure is more recent still. Argon2 support arrived in hashcat version 7.0.0, released on 4 August 2025 with work contributed by the Netherlands Forensic Institute, as hash-mode 34000. The tool’s own documentation shows a run dated 1 August 2025 against an Argon2id hash with m=65536, t=3, p=1 on a machine with two RTX 4090 cards, reporting 1,699 hashes per second on the first card and 1,696 on the second. Compare 1,699 with the same card’s 21,975,500,000 for raw SHA-256: a ratio of about 12.9 million to one.
| Store on one RTX 4090 | Guesses per second |
|---|---|
| SHA-256, unsalted | 21,975,500,000 |
| bcrypt cost 12 | about 1,438 |
| Argon2id m=64 MiB t=3 p=1 | 1,699 |
The last two lines of that table are the reason this chapter recommends what it recommends.
Salt: what it defeats, and what it does not#
A salt is a value, unique per stored password, generated by a cryptographically secure random number generator, stored in the clear alongside the digest, and mixed into the hash input so that identical passwords produce different digests. That is the entire definition. It is not secret and it is not a key; its length requirement is driven by collision probability, not secrecy.
Salt defeats exactly two things.
The first is precomputation. Without salt, an attacker can hash a large list of candidate passwords once, store the results sorted, and break any future stolen database by lookup instead of by computation. The extreme version is the rainbow table, a time-memory trade-off structure published by Philippe Oechslin in 2003 that stores chains rather than individual hashes and so covers a vastly larger space in the same storage; the next chapter covers how it works. The point here is structural: a table is built for one hash function, and if every password is prefixed with 16 random bytes, a separate table would be needed for each of 2 to the power 128 possible salts. Precomputation dies completely.
The second is cross-account amplification. Without salt, testing one candidate costs one hash evaluation however many accounts are in the file, because the resulting digest is compared against every row at once through a sorted index, so attacking a million accounts costs the same as attacking one. With unique salts, each candidate must be re-hashed once per distinct salt. On a stolen file with eight million users, unique salts multiply the attacker’s total work by eight million and the defender’s by nothing at all.
Salt defeats nothing else. It does not slow an attack on one chosen account by one instruction, it does not make a weak password strong, and it does not conceal that a password is weak. If the attacker wants Meera’s password and only Meera’s, the salt costs him one extra concatenation per guess, which is not measurable.
There is a third, quieter benefit: because identical passwords no longer produce identical digests, a stolen file no longer reveals which users share a password, nor the frequency distribution of passwords within it. In the Adobe case that structural leak was the primary attack.
Now the length question, which people answer badly. NIST SP 800-63B-4 requires that the salt be at least 32 bits. NIST SP 800-132 of December 2010 requires that the randomly generated portion be at least 128 bits. RFC 9106 recommends 128 bits for Argon2. Those look inconsistent; they are not, because 32 bits is a floor for regulatory conformance and 128 bits is the engineering answer. Here is why the difference matters.
With a 32-bit salt there are 4,294,967,296 possible values. In a database of 8,000,000 users, the expected number of pairs sharing a salt is roughly N squared divided by twice the salt space: 64,000,000,000,000 divided by 8,589,934,592, or about 7,450 colliding pairs, each restoring the bulk discount for those two accounts. With a 128-bit salt the same calculation gives a number indistinguishable from zero.
| Salt length | Distinct salts | Expected pairs at 8M users |
|---|---|---|
| 32 bits | 4.29 x 10^9 | about 7,450 |
| 64 bits | 1.84 x 10^19 | about 0.0000017 |
| 128 bits | 3.40 x 10^38 | about 1 x 10^-25 |
Use 128 bits, which is 16 bytes. Every modern library does this for you and encodes it into the output string; the only reason to know the number is to recognize a broken implementation when you see one.
Three implementation rules follow, and all three are broken regularly in production code. The salt must come from a cryptographically secure random source, not a general-purpose random function seeded by the clock. It must be generated fresh for every password, including on a password change, not reused from the previous record. And it must never be derived from anything about the user, because a salt derived from the username is the same at every site in the world for that username, and precomputation returns.
Pepper: a keyed secret outside the database#
A pepper is a secret value, the same for all users, held outside the password database and applied so that a stolen database is not sufficient to test guesses.
The idea is not fringe. NIST SP 800-63B-4 section 3.1.1.2 says verifiers SHOULD perform an additional iteration of a keyed hashing or encryption operation using a secret key, stored within a hardware-protected area such as a hardware security module or a trusted execution environment. The 2017 edition, section 5.1.1.2, said the same in different words: an additional iteration of a key derivation function using a salt value that is secret and known only to the verifier, stored separately from the hashed secrets.
There are three places you can put the pepper, and the choice determines whether you can ever change it.
The first is inside the KDF input: hash the concatenation of the pepper and the password. It is the simplest to write and it cannot be rotated, because changing the pepper would require every user’s password, which you do not have.
The second is a keyed hash applied to the password before the KDF, typically HMAC-SHA-256 with the pepper as the key. This has one real advantage over the first: it turns an arbitrary-length password into a fixed-length input, which sidesteps the bcrypt length limit discussed below, and it makes the pre-hash unshuckable, which we come to later. It also cannot be rotated in place.
The third is an outer layer over the finished digest: either a keyed hash or symmetric encryption of the digest. Encryption is the important case, because it is the only one of the three that can be rotated without any user involvement. To change the key you decrypt every row with the old key and re-encrypt with the new, offline, in a batch job, without ever seeing a password.
This is precisely what Dropbox described publicly on 21 September 2016, in a post by Devdatta Akhawe. Their scheme had three layers: SHA-512 applied to the password to normalize its length, then bcrypt with cost 10 and a per-user salt, then AES-256 encryption of the bcrypt output under a global key held separately from the database, which they named a pepper. The encryption layer is the rotatable one.
| Pepper placement | Rotatable | Notes |
|---|---|---|
| Mixed into KDF input | No | Simplest, least flexible |
| HMAC pre-hash of password | No | Fixes length, stops shucking |
| Encrypt the final digest | Yes | Rotate offline in batch |
Now the operational cost, stated without enthusiasm, because this is where pepper schemes die.
Availability. The key must be reachable on every authentication. A hardware security module on that path inherits your login availability target; a cloud key service adds a network dependency, a per-call latency and a rate limit you will eventually hit at peak.
Durability. If the key is lost, every password becomes unverifiable and every user must go through account recovery. That makes the key at least as precious as the database, which means backups, which means it exists in more places than one, which erodes the separation that justified it.
Rotation. Only the encrypted-digest construction rotates cleanly. The other two require either keeping every historical key forever and recording which one each row used, or waiting for each user to log in, which on a consumer service means old keys stay live for years.
Blast radius. The case the pepper defends is real and common: SQL injection, an exposed backup, a misconfigured storage bucket and a stolen replica all produce database contents without host access. The case it does not defend, an attacker with code execution on the authentication service, is the more serious intrusion, and there he does not need the key because he can ask the service to use it.
The recommendation that follows is not “always use a pepper”. It is: if you already have a hardware security module or a managed key service on your critical path, and an operations team that can rotate keys, add a pepper as an outer encryption layer over the digest. If you do not, spend the same effort on your KDF parameters, because a well-tuned Argon2id with no pepper is stronger than a badly operated pepper over a weak hash.
The four key derivation functions, compared#
A password-based key derivation function, in this context, is a function deliberately built to be expensive, taking a password, a salt and one or more cost parameters, and producing a fixed-length output. Four matter in 2026.
PBKDF2 is specified in RFC 8018, “PKCS #5: Password-Based Cryptography Specification Version 2.1”, published January 2017, obsoleting RFC 2898 of September 2000; PBKDF2 itself is section 5.2. It is the repeated application of a pseudorandom function, in practice HMAC with SHA-1, SHA-256 or SHA-512, with successive iteration outputs exclusive-ored together. Its cost parameter is a single iteration count. It has one property no rival has: it is the only password hashing scheme approved for United States federal use, through NIST SP 800-132 of December 2010, which is why it persists in regulated environments long after it stopped being the best choice. Its weakness is that it needs almost no memory, so a graphics card or a purpose-built chip parallelizes it almost perfectly.
There is a subtlety in PBKDF2 that most implementations get right by accident. The function produces output in blocks of the underlying hash size, and each block costs the full iteration count. Request a longer key than the native hash output and the defender pays for every block, while the attacker needs only the first to reject a wrong guess. Set the output length equal to the hash output length: 32 bytes for SHA-256, 64 for SHA-512.
bcrypt was published by Niels Provos and David Mazieres in “A Future-Adaptable Password Scheme” at the 1999 USENIX Annual Technical Conference. Its insight is in the title: build in a cost parameter so the function can be made more expensive as hardware improves, without changing the algorithm or the stored format. It is built on the Blowfish block cipher, using a modified key schedule called EksBlowfish that is deliberately expensive to set up and repeated 2 to the power cost times, with cost running from 4 to 31. Its inner loop makes heavy use of a 4 KiB table of S-boxes rewritten as it runs, which was a poor fit for the graphics hardware of 2010 and remains a moderately poor fit today; that accidental memory dependence is why bcrypt has aged as well as it has. Its limits are a maximum input of 72 bytes and a fixed, unadjustable memory footprint.
scrypt was introduced by Colin Percival in “Stronger Key Derivation via Sequential Memory-Hard Functions” at BSDCan in May 2009 and standardized as RFC 7914 in August 2016. It was the first widely used function designed explicitly to be memory-hard: to compute it you must fill and repeatedly access a large array, and using less memory costs disproportionately more time. Its three parameters are N, the cost, a power of two; r, the block size; and p, the parallelization factor. Memory used is about 128 times N times r bytes, so N of 2 to the power 17 with r of 8 uses 128 MiB.
Argon2 won the Password Hashing Competition, which ran from 2013 to the selection of Argon2 on 20 July 2015, with special recognition to Catena, Lyra2, yescrypt and Makwa. It was specified as RFC 9106, “Argon2 Memory-Hard Function for Password Hashing and Proof-of-Work Applications”, an Informational RFC of September 2021 by Alex Biryukov, Daniel Dinu, Dmitry Khovratovich and Simon Josefsson. Argon2d uses data-dependent memory addressing, which resists time-memory trade-off attacks best but leaks access patterns to anything watching the cache. Argon2i uses data-independent addressing, immune to that side channel but admitting better trade-off attacks. Argon2id runs as Argon2i for the first half of the first pass over memory and as Argon2d thereafter, getting most of both properties. RFC 9106 states that Argon2id MUST be supported by any implementation while Argon2d and Argon2i MAY be, and Argon2id is the variant to use for password storage.
| Function | Year | Cost parameters |
|---|---|---|
| PBKDF2 | 2000, RFC 8018 2017 | iterations |
| bcrypt | 1999 | cost (log2 rounds) |
| scrypt | 2009, RFC 7914 2016 | N, r, p |
| Argon2id | 2015, RFC 9106 2021 | m, t, p |
A fifth deserves a mention because it is what your Linux machine is probably using. yescrypt, by Solar Designer, one of the schemes given special recognition in the 2015 competition, builds on scrypt but uses SHA-256, HMAC and PBKDF2 as its cryptographic core, which makes it easier to defend where approved primitives are required. It is the default for local accounts in Debian 11, Fedora 35 and later, Kali Linux from 2021.1 and Ubuntu 22.04.
The recommendation, in one paragraph. Use Argon2id if you can, scrypt if your platform lacks it, and bcrypt if it lacks both, accepting the 72-byte limit by pre-hashing. Use PBKDF2-HMAC-SHA-256 only where a compliance regime recognizes no other function, and understand that you are then buying a certificate rather than security. Where experts disagree, they disagree about bcrypt against Argon2id at moderate memory settings: one camp holds that Argon2id at any deployable setting dominates, the other that bcrypt’s twenty-seven years of implementation maturity make it safer for teams without cryptographic expertise. Both positions are defensible and neither camp recommends a fast hash.
Tuning cost parameters against a latency budget#
Cost parameters are not values you copy from a blog post. They are values you measure on the hardware you will actually run, against a latency budget you have actually decided.
Start with the budget: how long may a login take, at your busiest minute, on your smallest server, under your worst-case concurrent load. For most consumer web services the answer is between 200 and 500 milliseconds of hashing; on constrained hardware it may be 100; for a password manager unlocking a vault, where the user expects a pause and there is exactly one of them, it may be a full second or more.
Then measure. Pick the parameters, run the function a few hundred times on the target hardware with nothing else running, take the median, and adjust. Then repeat under realistic concurrency, because a memory-hard function under fifty concurrent logins is competing for memory bandwidth and the numbers change.
The measurements below were taken for this chapter in August 2026 on a modest virtual machine with two cores of an Intel Xeon at 2.10 GHz, using the reference Argon2 library, the standard bcrypt library and Python’s built-in PBKDF2. The hardware is deliberately unimpressive, because tuning on a large machine and deploying to a small one is the most common way this goes wrong.
| Configuration | Median time |
|---|---|
| PBKDF2-SHA-256, 600,000 | 177 ms |
| bcrypt cost 10 | 76 ms |
| bcrypt cost 12 | 326 ms |
| bcrypt cost 14 | 1,248 ms |
| Argon2id m=12288 t=3 p=1 | 22 ms |
| Argon2id m=19456 t=2 p=1 | 24 ms |
| Argon2id m=47104 t=1 p=1 | 42 ms |
| Argon2id m=65536 t=3 p=1 | 143 ms |
| Argon2id m=262144 t=3 p=1 | 749 ms |
Two things fall out of that table. The first is that bcrypt doubles with every step, which is what a base-two logarithm parameter means and which people forget when they raise it from 10 to 14 in production and take the site down. The second is that the OWASP-recommended Argon2id settings are cheap even on this hardware: 19 MiB with two passes takes 24 milliseconds, well inside any budget, so most services can afford considerably more memory than the published minimum.
The current published recommendations, as of August 2026, come from the OWASP Password Storage Cheat Sheet, the reference most engineering teams actually use. For Argon2id it offers five configurations as roughly equivalent, trading memory against passes: m=47104 (46 MiB) with t=1 and p=1; m=19456 (19 MiB) with t=2 and p=1; m=12288 (12 MiB) with t=3 and p=1; m=9216 with t=4; and m=7168 with t=5. For scrypt, N of 2 to the power 17 with r=8 and p=1, which is 128 MiB, plus four lower-memory alternatives with higher parallelism. For bcrypt, a work factor as high as the server allows, minimum 10. For PBKDF2, 600,000 iterations with HMAC-SHA-256, 220,000 with HMAC-SHA-512, and 1,400,000 with HMAC-SHA-1 for legacy use only.
Set those against RFC 9106’s own recommendations, aimed at a different audience and far more aggressive: Argon2id with 2 GiB of memory, one iteration, four lanes, a 256-bit tag and a 128-bit salt as the first option, and 64 MiB with three iterations and four lanes as the second. The 2 GiB option is not deployable on a shared login server: on the machine measured above it took 7.49 seconds, and fifty concurrent logins would need 100 GiB. That is not a criticism of the RFC, but a reminder that a specification’s recommendation is written for a well-resourced single-user case such as disk encryption, and that a web service’s parameters must come from its own measurements.
It is useful to know which dial to turn. Memory hurts the attacker most, because it is the resource a graphics card or custom chip has least of per parallel unit. Iterations cost the defender least in memory terms but give the smallest defensive advantage. Parallelism should generally be 1 on a server handling many concurrent logins, since you want each login to occupy one core rather than several; the RFC’s p=4 assumes a single user with a whole machine. And parameters are not permanent: revisit them on a schedule, because hardware improves, and the upgrade mechanism below makes that possible without disruption.
The storage record, worked in full#
A password record is not a digest. It is a self-describing structure containing everything needed to re-derive and compare, so that the algorithm and parameters can change per record without a schema migration and without a lookup table. The near-universal convention is the modular crypt format and its modern descendant, the PHC string format, which most libraries emit by default.
Here is Meera’s password Pihu@2019, stored properly: a real value from the reference Argon2 implementation with a randomly generated 16-byte salt. The record is a single line with no spaces; it is broken across three lines here only because the page is not wide enough.
$argon2id$v=19$m=19456,t=2,p=1
$nyxBp7MOXWjB9KKbfgPVyA
$1yUjQ04PiGxgg/g4Dg9ymk9a7PgjAKs0s+Tb/oMwUck
Read left to right, the dollar-separated fields are these. The first, argon2id, is the algorithm identifier, naming the variant. The second, v=19, is the Argon2 version number, 19 decimal or 0x13, the version defined by RFC 9106. The third carries the cost parameters: m=19456 is memory in kibibytes, so 19 MiB; t=2 is the number of passes over memory; p=1 is the number of lanes. The fourth is the salt, 16 random bytes in base64 without padding, 22 characters. The fifth is the tag, or digest, 32 bytes in base64 without padding, 43 characters.
Everything a verifier needs is in that string and nothing else needs storing. If you later change the parameters, old records still verify because each carries its own.
Now the same password and parameters with a different random salt:
$argon2id$v=19$m=19456,t=2,p=1
$TYHgxnJauT8KbeQVgsewng
$TSog5csmVvJVCrfsM5bNoPoTIetH9XEPsKrLb2GPCxg
The two digests share nothing. An attacker sorting the file cannot tell these two accounts have the same password, and a candidate tested against one tells him nothing about the other. Compare the unsalted case, which is what LinkedIn had. SHA-256 of Pihu@2019 is:
652610d2bc7fb2c0011ea9c374e4f9d5e8f51e590254e5581d356d6f304910dc
Every account in the world using that password has exactly that string in its row. One computation, and the attacker has matched all of them.
Here is the same password in bcrypt format at cost 12, again a real value with a real random salt:
$2b$12$nAUXPyEKIP3LHWWIUj.s9.DqjESqGaqbSg0KWyTNt2Zr..NAjcwC6
The fields are laid out differently and it is worth knowing how. The prefix $2b$ identifies the algorithm variant. The 12 is the cost, meaning 2 to the power 12, or 4,096, iterations of the expensive key schedule. Then one dollar sign, then a fixed-width field of 53 characters with no separator: the first 22 are the 128-bit salt and the remaining 31 the 184-bit digest. The encoding is bcrypt’s own radix-64 alphabet, which begins with a full stop and a slash and is not interchangeable with standard base64.
The $2b$ prefix has history. The original was $2$, then $2a$. In June 2011 a bug was found in crypt_blowfish, a widely used implementation, in the handling of characters with the high bit set, which weakened hashes of passwords containing non-ASCII bytes. The fix introduced $2x$ for hashes from the broken code and $2y$ for hashes from the corrected code; OpenBSD separately introduced $2b$. Today $2b$ and $2y$ are equivalent, $2a$ is ambiguous and $2x$ is a warning label.
Putting it together, a sensible database row looks like this. There is no algorithm column, no parameter columns and no separate salt column, because all of that is inside the hash string.
{
"user_id": "u_8814027",
"email_hash": "b1c7... (not the subject here)",
"password_hash": "$argon2id$v=19$m=19456,t=2,p=1$nyxB...$1yUj...",
"password_updated_at": "2026-08-17T09:14:22Z",
"hash_scheme_version": 4,
"must_rehash": false
}
The hash_scheme_version field is optional and is a convenience: it lets you find every row on an outdated scheme with an indexed query instead of parsing every hash string.
The verification path, end to end#
Here is the whole operation, in order, with the traps marked.
password arrives over TLS
|
v
[1] normalize Unicode (NFKC), trim nothing else
|
v
[2] look up the user record
|
+--> not found: hash a dummy anyway, then fail
|
v
[3] read algorithm + params + salt from the stored
string; do NOT use your current defaults
|
v
[4] optional: HMAC the password with the pepper key
|
v
[5] run the KDF -> candidate tag
|
v
[6] constant-time compare candidate vs stored tag
|
v
[7] on success: does the record use current params?
if not, rehash from the plaintext we still hold
in memory, write it back, then discard
|
v
[8] discard the plaintext; never log it, never
put it in an error message or a trace
Step 1 matters because a password typed on a phone and the same password typed on a laptop can be different byte sequences for the same visible characters. Unicode normalization form NFKC, applied before hashing, makes them the same. Decide it once and never change it, because changing it invalidates every stored hash.
Step 2 is user enumeration. If your login returns in 4 milliseconds for an unknown username and 300 milliseconds for a known one, an attacker can list your entire user base by timing. Run a hash of comparable cost against a fixed dummy record when the user is not found.
Step 3 is what makes the whole upgrade mechanism possible, and it is the one beginners break. The parameters used for verification must come from the stored record, not from configuration. Read the cost from a config file and the day you raise it every existing user is locked out.
Step 6 is a constant-time comparison. An ordinary string comparison returns as soon as it finds a difference, leaking how many leading bytes matched. For a password digest that is a smaller risk than for a message authentication code, since the attacker cannot choose the digest, but every library provides a constant-time compare and there is no reason not to use it.
Step 8 is the most common real-world leak of all. Passwords end up in application logs, exception traces, request-body captures inside error monitoring tools and analytics payloads. Very large providers have disclosed exactly this. GitHub told affected users to reset their passwords at the start of May 2018 after a bug recorded plaintext passwords in internal logs; Twitter admitted the same thing on 3 May 2018; and in March 2019 Facebook disclosed that applications built by its own engineers had logged unencrypted passwords onto internal servers, in archives reaching back to 2012, affecting a number its own investigation put between 200 million and 600 million users. In every case the database itself was hashed correctly. All the cryptography in this chapter is defeated by one careless log line.
One further consideration. An expensive KDF is a resource amplifier: one unauthenticated request costs your server 300 milliseconds of processor time and 19 MiB of memory, so ten thousand requests per second against your login endpoint is a denial of service that costs the attacker almost nothing. Rate limit before the hash, not after, and bound the concurrency of the login path so a flood degrades logins rather than the whole service.
Upgrade on login: moving every record without a reset#
You have a database of eight million rows hashed with unsalted SHA-1, or bcrypt at cost 8, or PBKDF2 at 10,000 iterations. You want Argon2id. You cannot compute the new hashes, because you do not have the passwords. Forcing eight million people to reset is a business decision that costs a measurable share of them permanently, and it trains users to click password-reset links in emails, which is precisely the behaviour phishing depends on.
The mechanism that avoids all of this is upgrade on login, and it works because at exactly one instant you do have the plaintext: the moment the user logs in successfully.
The procedure is four steps. Verify the password against the stored record using the parameters in that record. If verification succeeds, check whether the record’s algorithm and parameters match your current target. If they do not, and the plaintext is still in memory from the verification you just did, compute a fresh hash with the current parameters and a fresh salt and write it back in the same transaction. Then discard the plaintext.
Every library supports this. PHP has password_needs_rehash, Python’s passlib has needs_update, Django’s hashers signal must_update, and Spring Security’s DelegatingPasswordEncoder stores a curly-braced algorithm prefix on every hash and exposes upgradeEncoding. The pattern is the same everywhere:
ok = verify(stored_hash, password) # uses params in
# stored_hash
if ok and needs_rehash(stored_hash, current_params):
new_hash = hash(password, current_params)
store(user_id, new_hash)
This handles active users and does nothing for dormant ones, and on a consumer service the dormant tail is long. So pair it with a policy, decided in advance and written down: after some period, twelve months being a common choice, records still on the old scheme are force-reset or the accounts disabled. The important thing is that the decision exists, because otherwise your breach exposure is defined by the worst scheme still present, not the best.
For records you want to strengthen tonight, without waiting for a login, there is a second technique: wrap the existing digest. If you store unsalted SHA-1, compute bcrypt of the stored SHA-1 value and store that, for every row, without any passwords. Verification becomes SHA-1 the submitted password, then bcrypt-verify the result. Every row is protected at once.
This has a trap that was not widely understood until around 2020, and it has a name: password shucking. Suppose you store bcrypt(md5(password)) as a wrapper over a legacy MD5 store. An attacker who obtains, from some other breach entirely, the raw MD5 of the same user’s password can crack that MD5 cheaply and confirm it against your bcrypt in a single bcrypt evaluation. Your bcrypt layer contributed one hash of work. The wrapper is only as strong as the inner value is unavailable elsewhere, and for a reused password it very often is available.
The defence is to wrap with a keyed function rather than a bare one: store bcrypt(HMAC-SHA-256(pepper_key, password)) rather than bcrypt(md5(password)). The attacker can now neither precompute nor shuck, because he cannot compute the inner value at all without the key. This is where the pepper and the upgrade path meet, and it is the strongest argument for having a key store at all.
One further trap with pre-hashing. bcrypt takes at most 72 bytes and silently ignores the rest, and some implementations also truncate at the first zero byte. Feed bcrypt the raw 32 binary bytes of a SHA-256 digest and any zero byte, which occurs in about 12 per cent of digests, truncates the input and destroys most of the strength. Encode the pre-hash as text first: base64 of a SHA-256 digest is 44 characters, comfortably under 72.
What the standards require, as of August 2026#
It is worth separating what is required, what is recommended and what is merely common, because the three get quoted interchangeably.
NIST SP 800-63B-4, “Digital Identity Guidelines: Authentication and Authenticator Management”, published 31 July 2025, is the requirement for United States federal systems and the de facto reference elsewhere. Its section 3.1.1.2 covers password verifiers. Passwords SHALL be salted and hashed using a suitable one-way key derivation function. The salt SHALL be at least 32 bits in length and SHALL be chosen arbitrarily to minimize salt value collisions among stored hashes. The key derivation function SHALL be an approved password hashing scheme published in the latest revision of SP 800-132. Verifiers SHOULD perform an additional iteration of a keyed hashing or encryption operation using a secret key stored in a hardware-protected area such as a hardware security module or a trusted execution environment.
Note what that reference to SP 800-132 implies. That document, “Recommendation for Password-Based Key Derivation: Part 1: Storage Applications”, was published in December 2010 and specifies PBKDF2 in section 5.3, with a minimum salt of 128 bits in section 5.1 and a recommended minimum iteration count of 1,000 in section 5.2, noting that 10,000,000 may suit especially critical keys. NIST posted a note in May 2023 stating an intention to revise it; as of August 2026 no revision has been published, so a strictly FIPS-conformant system in the United States is still restricted to PBKDF2, a function nobody would choose on the merits. The tension is openly acknowledged: the 2017 edition of SP 800-63B said in the same breath that a memory-hard function SHOULD be used because it increases the cost of an attack, while the approved list contained none.
| Source | Salt minimum | Function |
|---|---|---|
| SP 800-63B-4, July 2025 | 32 bits | Per SP 800-132 |
| SP 800-132, Dec 2010 | 128 bits | PBKDF2 |
| RFC 9106, Sept 2021 | 128 bits recommended | Argon2id |
| OWASP, current | Library default | Argon2id first |
The OWASP Password Storage Cheat Sheet is not a standard; it is a widely used community reference that most engineering teams treat as authoritative in practice. Its recommendations, given above, are the practical answer for a service not under a FIPS constraint.
Two further requirements from SP 800-63B-4 belong here, even though password policy is a different chapter’s subject, because they change what your storage layer must accept. Passwords used as a single factor SHALL be a minimum of 15 characters, and a minimum of 8 within multi-factor authentication; verifiers SHOULD permit a maximum length of at least 64 characters. The storage consequence is direct: your column must accommodate the output of your chosen function regardless of input length, you must not truncate before hashing, and you must not impose a maximum low enough to suggest you are storing the password itself. A service that rejects a 40-character passphrase is telling the world something about its database.
The failure modes that show up in real systems#
A short catalogue, because knowing the mechanism is not the same as shipping it correctly.
Silent truncation. bcrypt at 72 bytes, discussed above. Some older systems truncate at 8 characters, a hangover from the original Unix DES-based crypt of the 1970s, which used only the first eight. If your maximum length is 8, 16 or 20, someone is truncating.
Home-made iteration. Hashing twice with the same fast function gains almost nothing and can in principle lose a little. Iterating a fast hash inside a sound construction is exactly what PBKDF2 exists to do; do not write your own loop.
Truncated comparison. Some systems store only the first few bytes of the digest to save space. A 16-byte Argon2 tag is not meaningfully weaker than a 32-byte one, but truncating in the comparison, so that only the first few bytes are checked, turns a strong hash into a weak one and has appeared in real code.
Bringing Meera’s account to the end#
We began with Meera, her grocery service with eight million users, and the password Pihu@2019. Here is the full state of her record under the design this chapter recommends, and an honest statement of what it buys her.
The service stores the single string shown above: Argon2id, version 19, 19 MiB of memory, two passes, one lane, a 128-bit random salt unique to her, and a 256-bit tag, all in one self-describing field with no separate salt column. Verification reads the parameters out of her own record, so raising the cost next year will not lock her out. An outer AES-256 encryption of that tag, under a key held in a hardware security module, means a stolen database dump alone yields nothing testable. On her next login, if the parameters have moved on, her record is silently rewritten.
Now the theft. If the attacker takes the eight-million-row table and not the key, he has nothing he can test; the pepper has done its one job. If he takes the table and the key, which is the pessimistic and entirely plausible case, he faces Argon2id at 19 MiB per guess. The closest published measurement is 1,699 guesses per second on one RTX 4090 at 64 MiB and three passes; at 19 MiB and two passes it will be several times faster, so call it five to ten thousand per second per card, noting that this is an estimate scaled from a measurement at different parameters rather than a measured figure. At 8,000 guesses per second, the 30,800,000 candidates in the name-symbol-year space take about 64 minutes.
That is the honest ending. Sixty-four minutes for one account under a well-chosen modern scheme, against a shape of password millions of people use. Against the unsalted SHA-256 store the same search took about a millisecond, so the storage scheme bought a factor of roughly three and a half million, which is exactly what it is supposed to do and is not the same as safety.
What it buys at the scale that matters is the difference between an attacker recovering seven million of the eight million passwords in a week and recovering perhaps a hundred thousand in the same week: the difference between the end of the company and a bad month with a forced reset. And it is entirely the service’s decision, made months or years before the breach, in about forty lines of code.
14.98 Common wrong ideas#
Wrong: We are safe because we encrypt our passwords. Right: Encryption is reversible by design, so the system holds the means to recover every password and the key must be online continuously for logins to work; use a one-way function such as Argon2id, where recovery is impossible even for the operator, and reserve encryption for an outer layer over an already one-way digest.
Wrong: SHA-256 is a secure hash, so SHA-256 is secure for passwords. Right: SHA-256 resists inversion of a random input, but passwords are not random inputs; a single retail RTX 5090 computes about 28.4 billion SHA-256 hashes per second, so any password with a recognizable human shape falls in seconds, and the speed that is a virtue everywhere else is exactly what disqualifies it here.
Wrong: The salt must be kept secret. Right: A salt has exactly one job, to be unique per record, and it is stored in the clear next to the digest it belongs to; a secret value shared across users is a pepper, a different mechanism with different rotation, availability and durability requirements, and confusing the two means the pepper gets handled with the care due to a salt, which is none.
Wrong: Salting makes each password harder to crack. Right: Salting does not add one instruction to the cost of attacking one chosen account; it destroys precomputation, including rainbow tables, and removes the attacker’s bulk discount, so that attacking a million accounts costs a million times attacking one. It defends the database, not the individual.
Wrong: We iterate SHA-256 a hundred thousand times, which is the same as a proper key derivation function. Right: Iteration raises cost only in time, the resource attackers buy most cheaply; the modern defences are memory-hard, so each guess needs a large working area that graphics cards cannot replicate thousands of times over, and a hand-rolled loop also forgoes the review PBKDF2, bcrypt, scrypt and Argon2id have each received.
Wrong: Argon2id with the RFC’s recommended 2 GiB setting is what we should deploy. Right: RFC 9106’s first option targets a single-user, well-resourced case such as disk encryption; on the machine measured for this chapter it took 7.49 seconds per hash, and fifty concurrent logins would need 100 GiB, so a web service should tune from its own measurements against a latency budget, typically landing between 19 and 64 MiB.
Wrong: Upgrading our hashing means forcing everyone to reset their password. Right: At the instant of a successful login you hold the plaintext, so you can verify against the old record, recompute with the new algorithm and parameters, and write the record back, upgrading every active user silently; dormant accounts need a dated policy, and legacy digests can be wrapped in a keyed function tonight without any passwords at all.
Wrong: A pepper in a hardware security module makes a database breach harmless. Right: A pepper defends only the case where the database contents leak while the key does not, which is real but narrow; it does nothing against an attacker with code execution on the authentication service, and it introduces an availability dependency, a catastrophic loss scenario and a rotation problem that only the encrypt-the-digest construction solves.
Wrong: Our storage is strong, so our users’ passwords are safe. Right: Storage sets the price per guess and nothing else; a password among the ten thousand most common falls within seconds at any cost setting you can afford at your own login page, so storage changes a breach from total loss to partial loss and is not a substitute for length, uniqueness and a breach-corpus check.
14.99 Chapter summary in 20 lines#
- A password verifier must check a password without being able to learn it, and every storage layer defends one specific attack on that goal.
- Plaintext storage loses every password the moment anyone reads the column, as the RockYou breach of December 2009 showed with 32,603,388 readable passwords.
- Reversible encryption is not a middle ground, because the key must be online for every login and so sits inside the same blast radius as the data.
- Adobe’s October 2013 breach of about 153 million records used Triple DES in ECB mode, so identical passwords produced identical ciphertext and readable hints finished the job.
- Preimage resistance means that given an output it is infeasible to find any input producing it, at a generic cost of 2 to the power n for an n-bit output.
- That property is irrelevant to the real attack, because the attacker never inverts anything; he runs the function forwards over a short list of strings people actually choose.
- A single retail RTX 5090 computes about 28.35 billion SHA-256 hashes per second, measured in February 2025, exhausting a 30.8 million candidate space in about a millisecond.
- LinkedIn’s unsalted SHA-1 store produced 6.5 million public hashes in June 2012 and around 117 million account records in May 2016.
- A salt is a per-record random value, at least 128 bits, stored in the clear, whose only job is to be unique.
- Salt defeats precomputation, including rainbow tables, and cross-account amplification, so attacking a million accounts costs a million times attacking one.
- Salt defeats nothing else: it adds no measurable cost to an attack on one chosen account and makes no weak password strong.
- A pepper is a secret common to all users, held outside the database, and only the construction that encrypts the finished digest can be rotated without user involvement.
- Dropbox published its three-layer scheme on 21 September 2016: SHA-512, then bcrypt at cost 10 with a per-user salt, then AES-256 under a separately held pepper.
- bcrypt came from Provos and Mazieres at USENIX in 1999 with an adjustable cost, scrypt from Percival in 2009 introducing memory-hardness, and Argon2 won the Password Hashing Competition on 20 July 2015.
- RFC 9106 of September 2021 specifies Argon2, requires Argon2id support, and recommends 128-bit salts and 256-bit tags.
- OWASP’s current guidance is Argon2id at m=19456, t=2, p=1 or an equivalent trade, scrypt at N of 2 to the power 17, bcrypt at a work factor of at least 10, or PBKDF2-HMAC-SHA-256 at 600,000 iterations.
- Cost parameters must be measured on the target hardware against a latency budget, and read back from each stored record so raising them never locks anyone out.
- Upgrade on login rehashes a record at the moment of successful verification, and legacy digests can be wrapped tonight in a keyed function to avoid password shucking.
- A complete record is self-describing, in the form dollar-argon2id, dollar-v=19, dollar-parameters, dollar-salt, dollar-tag, and needs no other columns.
- NIST SP 800-63B-4 of 31 July 2025 requires a salt of at least 32 bits and an approved scheme from SP 800-132, which as of August 2026 still means PBKDF2, a conflict with best practice that NIST openly acknowledges.
Chapter sources: RFC 9106, “Argon2 Memory-Hard Function for Password Hashing and Proof-of-Work Applications”, Informational, September 2021, by Alex Biryukov, Daniel Dinu, Dmitry Khovratovich and Simon Josefsson, for the Argon2id variant rule, version number 19, the 16-byte salt recommendation, the 256-bit tag and the recommended options of 2 GiB with one pass and 64 MiB with three passes; RFC 8018, “PKCS #5: Password-Based Cryptography Specification Version 2.1”, Informational, January 2017, by Kathleen Moriarty, Burt Kaliski and Andreas Rusch, obsoleting RFC 2898, for PBKDF2 in section 5.2; RFC 7914, “The scrypt Password-Based Key Derivation Function”, Informational, August 2016, by Colin Percival and Simon Josefsson; Niels Provos and David Mazieres, “A Future-Adaptable Password Scheme”, 1999 USENIX Annual Technical Conference; Colin Percival, “Stronger Key Derivation via Sequential Memory-Hard Functions”, BSDCan, May 2009; the Password Hashing Competition results of 20 July 2015, naming Argon2 the winner with special recognition to Catena, Lyra2, yescrypt and Makwa; the OWASP Password Storage Cheat Sheet as read in August 2026, for the Argon2id and scrypt configurations, the bcrypt minimum work factor of 10 and the PBKDF2 iteration counts of 600,000, 220,000 and 1,400,000; NIST Special Publication 800-63B-4, “Digital Identity Guidelines: Authentication and Authenticator Management”, 31 July 2025, section 3.1.1.2, with the June 2017 edition’s section 5.1.1.2 for the earlier wording; NIST Special Publication 800-132, “Recommendation for Password-Based Key Derivation: Part 1: Storage Applications”, December 2010, sections 5.1, 5.2 and 5.3, and the NIST planning note of May 2023 announcing an intention to revise it; Robert Morris and Ken Thompson, “Password Security: A Case History”, Communications of the ACM, volume 22, number 11, November 1979, for the introduction of salt; Philippe Oechslin, “Making a Faster Cryptanalytic Time-Memory Trade-Off”, CRYPTO 2003; the hashcat benchmark of the NVIDIA RTX 4090 published 14 October 2022 on hashcat v6.2.6 and of the RTX 5090 published 10 February 2025 on v6.2.6-851; the hashcat 7.0.0 release notes and project documentation of August 2025 for Argon2 hash-mode 34000, the contribution from the Netherlands Forensic Institute and the 1,699 hashes per second figure on an RTX 4090; Devdatta Akhawe, “How Dropbox securely stores your passwords”, 21 September 2016; Paul Ducklin’s analysis of the Adobe breach for Sophos Naked Security, November 2013; the Imperva analysis of the RockYou breach, 2010; Troy Hunt on the LinkedIn data set, May 2016; the GitHub and Twitter plaintext-logging disclosures of early May 2018 and Brian Krebs’s report of the Facebook plaintext password logging of March 2019, with Facebook’s own statement of the same date; the crypt_blowfish advisory of June 2011 that produced the $2x$ and $2y$ prefixes; the Fedora change proposal adopting yescrypt from Fedora 35 and the corresponding Debian 11 default; and the PHP manual for password_hash, recording the rise of the default bcrypt cost from 10 to 12 in PHP 8.4.0. Timings described as measured for this chapter were taken in August 2026 on a two-core Intel Xeon at 2.10 GHz using the reference Argon2 library, the bcrypt 5.0.0 Python library and Python’s built-in PBKDF2.