Hashing & Salting
The message is from a security researcher, not a customer. A file called
users.csv is being passed around a forum, and its column names are yours.
Somebody got a copy of the users table — through an injectable query, a public
backup bucket, a laptop with a database dump on it — and the password column
came with it. Whatever that column holds now belongs to someone with unlimited
time and hardware of their own, and nothing you change on your servers today
reaches their copy.
That is the day password storage is designed for: not the login form, which you control, but the breach, which you did not. If the column holds passwords as typed, the attacker has everything, and not only on your site. People reuse passwords, so your users’ email and bank accounts are now exposed through you.
Why not encrypt them
The obvious fix is to encrypt the column, and it fails for a structural reason rather than a mathematical one. Encryption is reversible by design: whoever holds the key gets every password back. Your login code needs that key, so it lives somewhere the application can read it — an environment variable, a config file, a secrets service. The attacker who copied the database usually came in through that same application or its backups, and the key tends to travel with the data.
A login never needs the password back. It only has to answer one question: is this the password chosen at signup? Run the password through a one-way function — a cryptographic hash such as SHA-256 — and store only the output, called the digest. At login, hash what was typed and compare digests. There is no key to steal and no way to run the function backwards. The only route from a digest to a password is to guess one, hash it, and see whether the digests match.
Why a fast hash fails anyway
That last sentence is the whole attack, and a plain hash makes it cheap in three
ways. Identical passwords give identical digests: if Ada and Bob both chose
letmein, both rows read 1c8b…, so the attacker knows they match before
cracking anything, and cracking one cracks both. The digest of a password is
also the same on every site that stores it this way, so attackers work in
advance: hash millions of known passwords once, keep the pairs, and every
unsalted breach becomes a lookup. Rainbow tables are a more compact form of the
same idea. And SHA-256 is fast on purpose, because checksums and signatures need
it to be; on graphics cards an attacker computes fast hashes by the billions per
second.
This is a different job from the hash inside a hash table. A table’s hash only has to be quick and spread keys evenly; it does not need to be one-way, because nothing about a bucket number is secret. SHA-256 adds one-wayness. A password hash adds the property neither of them has: it is deliberately slow.
Salt: a different hash for every user
A salt is a random value, typically 16 bytes from a cryptographic random
generator, created fresh for each user whenever they set a password. It is
hashed together with the password and stored in the clear, right beside the
result. It is not a secret. Its job is to give every user a different hash: with
Ada’s salt mixed in, letmein gives one digest; with Bob’s, an unrelated one.
That is why it must be unique. One salt shared by every user still gives equal
passwords equal digests, and the attacker simply builds one table for it.
The scene runs the same two signups twice, against the same attacker: above, straight into SHA-256; below, with a salt drawn first. The badge on the attacker counts the lookups it makes in its precomputed table.
Predict first
Ada and Bob both choose letmein. Both versions of the users table leak to an attacker holding a precomputed table of common passwords and their SHA-256 digests. Of the four leaked rows, how many can that table crack?
Show the answer
The answer is C. The reasoning for each option is now shown under it.
a password, salt or digest · badge = lookups · rust = match, teal = no match
Two users, one password, stored two ways and then leaked — use Next step to go one at a time.
The attacker still has the salted rows, salts included. What is gone is
everything that relied on two digests being comparable. Ada and Bob no longer
look alike. A precomputed table is useless, because nobody could build one for
salts that did not exist yet. And one guess no longer tests every account at
once: letmein must be hashed with Ada’s salt, then again with Bob’s. The salt
turns the attacker’s work from a cost per password into a cost per user.
What it does not change is the price of one guess. The attacker reads Bob’s salt
from his row, hashes letmein with it, and compares: one SHA-256, as cheap as
ever.
Slow on purpose
Password hashing functions go after that price. bcrypt, scrypt and Argon2id are the three to know, and all are slow by design, with a cost setting you choose. bcrypt’s work factor doubles the work with every step up; scrypt and Argon2id also demand a set amount of memory for every hash. That matters because graphics cards get their speed from thousands of cores side by side, each with little memory of its own, so a guess that needs many megabytes takes most of that parallelism away. For new code Argon2id is the usual first choice; scrypt and bcrypt remain sound.
Put numbers on the three designs, with round, illustrative figures rather than benchmarks: 1,000,000 users leak, and the attacker has 10 million likely passwords and hardware that computes a billion fast hashes a second.
- Unsalted SHA-256. Hash the list once and compare each digest against every row: 10 million hashes, a hundredth of a second, every user at once.
- Salted SHA-256. The list is hashed again for each salt: 10 million × 1,000,000 is 10¹³ hashes, or 10,000 seconds. Under three hours.
- Salted and slow. A cost that makes each hash 100,000 times the work of one SHA-256 turns a billion a second into 10,000. The same job takes 10⁹ seconds, about 32 years in all, or 17 minutes for each user, and every doubling of the cost doubles both figures.
Your server pays that price too, at every login, so the cost is set to what a
login can afford and raised as hardware gets faster. And slow only slows
guessing: letmein is near the top of every list and still falls in under a
second. The defense for the worst passwords is refusing them at signup, checked
against lists of passwords already seen in breaches.
A login repeats the signup: read the salt from the row, hash the attempt with it at the same cost, and compare with the stored hash in constant time. An ordinary equality check can stop at the first differing byte, so its timing hints at how much of a guess was right. Against a slow, salted hash that is hard to exploit, but the safe comparison costs nothing. You may also meet a pepper, a secret key mixed into every hash and kept outside the database. It helps only if it truly lives elsewhere, the condition that sank encryption, and it replaces none of the above.
Node ships scrypt in its standard library, so the whole scheme is a few lines:
import { randomBytes, scrypt, timingSafeEqual } from 'node:crypto';
/** The cost. Doubling N doubles the time and the memory of every hash. */
const COST = { N: 2 ** 17, r: 8, p: 1, maxmem: 256 * 1024 * 1024 };
function derive(password: string, salt: Buffer): Promise<Buffer> {
return new Promise((resolve, reject) => {
scrypt(password, salt, 32, COST, (error, key) =>
error ? reject(error) : resolve(key),
);
});
}
async function hashPassword(password: string): Promise<string> {
const salt = randomBytes(16); // new for every user, stored in the clear
const hash = await derive(password, salt);
return `${salt.toString('hex')}:${hash.toString('hex')}`;
}
async function verifyPassword(password: string, stored: string) {
const [salt = '', hash = ''] = stored.split(':');
const actual = await derive(password, Buffer.from(salt, 'hex'));
return timingSafeEqual(actual, Buffer.from(hash, 'hex'));
}
The stored string is the salt and the hash, joined by a colon. It uses the
callback form of scrypt because scryptSync would block the event loop for
the whole of every hash. Real formats carry more: a bcrypt string starts with
its version and cost, as in $2b$12$, and bcrypt and Argon2 libraries generate
the salt for you. They store the cost because the cost will change. Raise N
here and every existing user is locked out, because their hashes were made with
the old one.
Try it
Raise the cost without locking anyone out
Make the cost travel with each hash. Store N in the string alongside the salt
and the hash, verify every password with the N it was hashed at, and add a
logIn(user, password) that, after a successful check, rehashes the password at
today’s cost if the stored one is lower. A hash made at N: 2 ** 14 should still
let its user in, and come out of that login at 2 ** 17.
Hint
The row is the only thing that remembers how its hash was made, so the cost has to be part of the stored string. And the only moment you can rehash is a successful login: it is the one time the server holds the plain password.
Solution
interface User {
passwordHash: string;
}
/** Today's cost. Raise it, and every stored hash still verifies at its own. */
const CURRENT_N = 2 ** 17;
function derive(password: string, salt: Buffer, N: number): Promise<Buffer> {
const cost = { N, r: 8, p: 1, maxmem: 256 * 1024 * 1024 };
return new Promise((resolve, reject) => {
scrypt(password, salt, 32, cost, (error, key) =>
error ? reject(error) : resolve(key),
);
});
}
async function hashPassword(password: string, N = CURRENT_N) {
const salt = randomBytes(16);
const hash = await derive(password, salt, N);
return `${N}:${salt.toString('hex')}:${hash.toString('hex')}`;
}
async function verifyPassword(password: string, stored: string) {
const [cost = '', salt = '', hash = ''] = stored.split(':');
const N = Number(cost);
const actual = await derive(password, Buffer.from(salt, 'hex'), N);
const ok = timingSafeEqual(actual, Buffer.from(hash, 'hex'));
return { ok, needsRehash: ok && N < CURRENT_N };
}
async function logIn(user: User, password: string): Promise<boolean> {
const { ok, needsRehash } = await verifyPassword(password, user.passwordHash);
if (needsRehash) user.passwordHash = await hashPassword(password);
return ok;
}Verifying with the stored N is what keeps old users able to log in; comparing
it with CURRENT_N is what moves them forward, one login at a time. A user who
never logs in again keeps the old cost indefinitely, which is the limit of this
approach: when that matters, teams also hash the old stored hash again at the
new cost, so every row is upgraded at once without anyone’s password. And rows
written by the original sample have no cost field, so a real migration would
read a two-part string as the old N.
To see it work, with a hash made at last year’s lower cost:
const user: User = { passwordHash: await hashPassword('letmein', 2 ** 14) };
console.log(user.passwordHash.split(':')[0]); // 16384
console.log(await logIn(user, 'letmein'), user.passwordHash.split(':')[0]);
// true 131072
console.log(await logIn(user, 'letmeout')); // falseCheck yourself
Pick an answer to lock it in. You then get the reasoning behind every option, including the ones you didn't pick. Any you miss come back on the review page a day later.
A breach report says 3,000 rows in your stolen users table share one identical hash. Each was stored as SHA-256 of the password plus one site-wide salt kept in the app config. What went wrong?
Show the answer
The answer is B. The reasoning for each option is now shown under it.
A pull request stores sha256(salt + password), with a fresh 16-byte random salt per user. You are reviewing it. Which change matters most?
Show the answer
The answer is D. The reasoning for each option is now shown under it.
A teammate proposes encrypting passwords with AES, the key in an environment variable on the app servers, so support can read a password out to a user who forgot it. What is the problem?
Show the answer
The answer is A. The reasoning for each option is now shown under it.
Logins use bcrypt at cost 10, and each step up doubles the work. You raise it to 12. An attacker could test 4,000 guesses a second against one stolen cost-10 hash. How many a second against a hash made at cost 12?
Show the answer
The answer is C. The reasoning for each option is now shown under it.
Key takeaways
Never store a password in a form that can be turned back into it: a login only has to compare, so a one-way hash is enough and a key is a liability.
A salt that is random, unique per user and stored beside the hash defeats precomputed tables and identical rows, but does not make one guess cost any more.
Argon2id, scrypt and bcrypt are slow on purpose, with a cost you raise as hardware improves, so store that cost with every hash.
Verify by hashing the attempt again with the stored salt and cost, and compare the results in constant time.