10 · Security

Hashing & Salting

8 min read · 5 questions · 1 exercise

A password is stored as a salted, deliberately slow hash, so a stolen table holds nothing to decrypt and every guess costs the attacker real work, one user at a time.

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?

  1. One-way means a digest cannot be run backwards, not that it cannot be recognized. The table was built by hashing letmein in advance, so finding 1c8b… in it is a lookup, and the answer is the password.

  2. One lookup, but it opens two accounts. Both unsalted rows hold 1c8b…, so the answer found for one is the answer for the other: every user with that password falls together.

  3. Both unsalted rows hold 1c8b…, which one lookup turns back into letmein. Neither salted row is in the table, which holds digests of bare passwords; nobody could precompute digests for salts drawn at signup. The badge ends on 3: one lookup above, two below.

  4. The salts are in the rows, but the table was computed without them, so knowing a salt does not make its digest appear there. Using the salt means hashing guesses with it, one user at a time, which is guessing rather than a lookup.

Show the answer

The answer is C. The reasoning for each option is now shown under it.

One password, stored two ways

a password, salt or digest · badge = lookups · rust = match, teal = no match

UNSALTEDSALTED
Ada
Bob
SHA-256
users
attacker
Ada
Bob
SHA-256
users
salt

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')); // false

Check 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?

  1. Leaked or not, those 3,000 rows would still match each other, because they share one salt and one password. The report describes identical digests, and no amount of secrecy about the salt prevents those.

  2. A salt only separates users when each has their own. With one salt for everyone, equal passwords still give equal digests, and an attacker builds one table for that salt. A fresh random salt per user would have made 3,000 different digests.

  3. The rows match because the inputs match, not because the output ran out of room. A longer digest of the same input is still the same digest every time it is computed.

  4. They did, and per-user salts exist for exactly that case. Nothing stops users choosing alike; the storage can still be stopped from showing it.

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?

  1. A salt works in plain view: it makes every digest different even when the attacker can read it. Encrypting it adds a key to protect and leaves the real weakness where it was.

  2. SHA-512 is still a fast hash, built to be quick. Guessing depends on how cheap one hash is to compute, not on how long its output is.

  3. That doubles a cost measured in billionths of a second. Repetition is a real technique, and PBKDF2 repeats a hash hundreds of thousands of times, but at that point you are choosing a tunable cost, which is the actual fix.

  4. The salt already defeats precomputed tables and identical rows. It cannot make one guess expensive, and with a fast hash the attacker still tests billions a second against each user. A slow, tunable password hash is the missing piece.

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?

  1. The table and its key usually sit on, and are backed up from, the same systems, so the breach that leaks one tends to leak the other, and then every password comes back at once. Support wanting to read passwords is the warning sign: a login never needs one back.

  2. AES with a random 128-bit or 256-bit key is far beyond brute force. The cipher is fine; the trouble is where its key has to live.

  3. They can: decrypt and compare. That is exactly the problem, because anything the server can decrypt, an attacker who has the key can decrypt too.

  4. Only in a deterministic mode. With a fresh random nonce, the usual way to run AES-GCM, equal passwords encrypt differently, and either way the one key still unlocks every row.

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?

  1. That counts two steps as one doubling. Each step doubles the work, so two steps quadruple it.

  2. That scales the work with the number itself, 12 against 10. The cost counts doublings: going from 10 to 12 is 2 × 2 = 4 times the work.

  3. Two doublings make each guess four times the work, so 4,000 ÷ 4 = 1,000. That holds only for hashes made at cost 12: until a user logs in and is rehashed, their row still holds a cost-10 hash, which falls at the old 4,000 a second.

  4. That divides by ten, as if each step multiplied the work tenfold. The cost counts doublings, so two steps divide the rate by 4.

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.