Skip to content
wannacode

Passwords

In short: Passwords are stored as a slow salted hash (bcrypt or argon2id), not encrypted and not with fast SHA-256, so a database leak does not give away the passwords themselves.

Problem

The backup that went public

The shop keeps its buyers in a users table: an email and a password. Somebody uploads a database backup to a public folder, and a week later it is on the internet. What the buyers lose depends only on how the password was stored:

  • as text: every password is out in the open. Many people use one password everywhere, so their email accounts leak too;
  • encrypted with a key that sits next to the database: the same. The key is in the configuration of the same server and leaks with it;
  • as SHA-256 with no salt: the passwords are not visible, but equal passwords give equal hashes. The table itself shows who shares a password with whom. And whoever has the table can take a list of common passwords, hash each one and look for matches: that is a dictionary attack, and SHA-256 was built to be fast.

A leak can never be ruled out completely. So the password is stored in a way that a leak does not hand out the passwords.

The idea

Keep the fingerprint, not the password

The server does not need to know the password. It only has to check whether the typed password is the same one. That is what a cryptographic hash is for: a function that turns text into a fingerprint of fixed length and cannot be reversed. The same input gives the same fingerprint, and the fingerprint does not lead back to the input.

This is not the hash from the Map and Set lesson: that one is fast, short and collides often, because it is made for buckets. A cryptographic hash is a different kind.

The shop's old code keeps the SHA-256 of the password:

const sha256 = (text) =>
createHash("sha256").update(text).digest("hex");
// The shop's old code: SHA-256 without a salt.
function register(email, password) {
return { email, salt: "", hash: sha256(password) };
}

Four buyers; ana and vera have the same password. The function sharedRows counts the rows whose hash appears more than once:

function sharedRows(users) {
const perHash = new Map();
for (const { hash } of users) {
perHash.set(hash, (perHash.get(hash) ?? 0) + 1);
}
return users.filter((row) => perHash.get(row.hash) > 1).length;
}
const users = [
register("ana@example.com", "qwerty"),
register("boris@example.com", "sofia2024"),
register("vera@example.com", "qwerty"),
register("georgi@example.com", "Kp7#vR!q2Lm9"),
];
console.log("The users table:");
for (const row of users) {
console.log(row.email.padEnd(20) + row.hash.slice(0, 16) + "…");
}
console.log("Rows that share a hash:", sharedRows(users));
Output
The users table:
ana@example.com 65e84be33532fb78…
boris@example.com 6191711a1871a6bd…
vera@example.com 65e84be33532fb78…
georgi@example.com 9cbd9835924cf733…
Rows that share a hash: 2

The rows of ana and vera are equal. Without breaking anything, anyone with the table sees that the two have one password. If one of them gives it away, she gives away the other's too.

The idea

A salt: every row different

The fix for equal rows is a salt: random bytes, different for every row, that go next to the password before the hash. The salt is not a secret. It sits openly in the row, because the login needs it again.

// A random salt of its own for every row.
function register(email, password) {
const salt = randomBytes(16).toString("hex");
return { email, salt, hash: sha256(salt + password) };
}

randomBytes from node:crypto is a cryptographic random number generator: its next value cannot be predicted. (Why the ordinary generator will not do is in the cryptography lesson.)

The salt is different on every run, so the program prints only what does not change:

const users = [
register("ana@example.com", "qwerty"),
register("boris@example.com", "sofia2024"),
register("vera@example.com", "qwerty"),
register("georgi@example.com", "Kp7#vR!q2Lm9"),
];
console.log("Same record for ana and vera:", users[0].hash === users[2].hash);
console.log("Rows that share a hash:", sharedRows(users));
Output
Same record for ana and vera: false
Rows that share a hash: 0

Equal passwords no longer show, and one computation counts for one row only. But SHA-256 is still fast: a graphics card computes billions a second, so the dictionary attack still works, just row by row. The hash also has to be slow on purpose.

See it

The users table: no salt, a salt, a slow hash

// demo; on a real server 2 ** 17 (OWASP)
const N = 2 ** 12;
function hashPassword(password) {
const salt = randomBytes(16);
const hash = kdf(password, salt, N);
return ["scrypt", N, salt.toString("base64"),
hash.toString("base64")].join("$");
}
function verify(password, record) {
const [, n, salt, expected] = record.split("$");
const actual = kdf(password, Buffer.from(salt, "base64"),
Number(n));
const want = Buffer.from(expected, "base64");
return timingSafeEqual(actual, want);
}
Step by step, in words
  1. The users table with the shop's old code: an email and the SHA-256 of the password, with no salt.
  2. Equal passwords give equal hashes. ana and vera have the same value, so the table itself shows who shares a password: 2 rows.
  3. Now every row gets a random salt of its own, and the row keeps SHA-256(salt + password).
  4. Every row is different, ana and vera too: 0 rows share a hash. But SHA-256 is fast, so each guess at a row is still cheap.
  5. The fix is a slow hash: scrypt with cost N = 4,096 here (a demo), which also needs memory. On a real server N = 2^17.
  6. The record keeps everything needed to check it later: algorithm $ cost $ salt $ hash. The password itself is not in it.
  7. At login the code reads the cost and the salt from the record and hashes the typed password the same way.
  8. The two hashes are compared in constant time: "qwerty" gives ✓, "qwerty1" gives ✗. Equal passwords no longer show, and every guess is slow.

The last frame puts the three steps together: the column keeps a record with a salt and a cost, no two rows are equal, and the login checks the password against the record.

The idea

A slow hash and the record

A slow password hash repeats its work many times. How many times is the cost (work factor). A login computes it once and waits a fraction of a second. Whoever has the leaked table pays the same price for every guess.

The standard library has scrypt: besides time it needs memory, and memory is expensive for graphics cards.
const kdf = (password, salt, n) =>
scryptSync(password, salt, 32, { N: n });
scryptSync takes the password, the salt, the length in bytes and N (the cost, a power of two). On a real server use the asynchronous crypto.scrypt: the synchronous one blocks the event loop for the whole computation (see the async lesson).

The record keeps everything the check needs: algorithm, cost, salt and hash, with $ between them. That way the cost can go up later without breaking the old records.

// demo; on a real server 2 ** 17 (OWASP)
const N = 2 ** 12;
function hashPassword(password) {
const salt = randomBytes(16);
const hash = kdf(password, salt, N);
return ["scrypt", N, salt.toString("base64"),
hash.toString("base64")].join("$");
}
function verify(password, record) {
const [, n, salt, expected] = record.split("$");
const actual = kdf(password, Buffer.from(salt, "base64"),
Number(n));
const want = Buffer.from(expected, "base64");
return timingSafeEqual(actual, want);
}
const record = hashPassword("qwerty");
const parts = record.split("$");
console.log(`Algorithm and cost: ${parts[0]}, ${parts[1]}`);
console.log("Log in with the right password:", verify("qwerty", record));
console.log("Log in with a wrong password:", verify("qwerty1", record));
console.log("One password, two records, equal:", record === hashPassword("qwerty"));
Output
Algorithm and cost: scrypt, 4096
Log in with the right password: true
Log in with a wrong password: false
One password, two records, equal: false

The cost in the program is small, so that it runs fast. On a real server OWASP recommends N = 2¹⁷ (r = 8, p = 1) for scrypt. In a real project you usually do not write this yourself but use bcrypt or argon2id from a proven library (details under "More").

Try it

Predict the output

One and the same password, hashed twice. What will it print?

const first = hashPassword("qwerty");
const second = hashPassword("qwerty");
console.log(first === second);
console.log(verify("qwerty", first));
console.log(verify("qwerty", second));
Answer

Try it

Fill in

At login the two hashes are compared. An ordinary comparison stops at the first different byte, and the response time can reveal how far they match. Which comparison always goes through all the bytes?

const want = Buffer.from(expected, "base64");
return (actual, want);

Try it

Find the bug

A colleague "simplifies" the code: instead of a random salt for every row, one salt for all. The slow hash is the same. Where is the problem?

Pick the line with the bug.

See it

The cost grows over the years

Computers get faster, so a cost that was enough last year is not enough some time later. Old records cannot be recomputed: a hash does not go backwards, and the server has the password only at the moment of a login. So the new hash happens on a successful login. Here hashPassword takes the cost as a parameter, and every record keeps its own, so the old records go on working.

// last year it was 2 ** 11
const N = 2 ** 12;
const costOf = (record) => Number(record.split("$")[1]);
function login(account, password) {
if (!verify(password, account.record)) return false;
if (costOf(account.record) < N) {
// only now do we hold the password: hash it at the new cost
account.record = hashPassword(password, N);
}
return true;
}
const account = { record: hashPassword("qwerty", 2 ** 11) };
console.log("Cost before the login:", costOf(account.record));
console.log("Log in:", login(account, "qwerty"));
console.log("Cost after the login:", costOf(account.record));
Output
Cost before the login: 2048
Log in: true
Cost after the login: 4096
More precisely, if you are curious

Which settings (OWASP, Password Storage Cheat Sheet):

  • argon2id: 19 MiB of memory, 2 iterations, parallelism 1. The first choice for a new project;
  • scrypt: N = 2¹⁷, r = 8, p = 1. It needs 128 MiB of memory, and in both Python and Node the default limit is 32 MiB, so it is raised with maxmem;
  • bcrypt: cost 10 or more. It uses only the first 72 bytes of the password;
  • PBKDF2-HMAC-SHA256: 600,000 iterations, when a FIPS-approved algorithm is required.

What real projects use. You rarely write this yourself; you use the framework's library: The npm packages argon2 and bcrypt (Node has neither built in). They give asynchronous hash and verify and write the algorithm and cost into the record.

Pepper. Some also add a secret value, shared by all rows, that lives outside the database (in a secret store, see the secrets lesson). A leak of the database alone is then not enough. It helps, but it does not replace the salt and the slow hash.

The password policy. A long password (at least 12 characters) and a check against lists of leaked passwords help more than "a capital letter and a digit" rules. That is also the exercise below.

How it is in the other languages: Java and C# have PBKDF2 in the standard library but no scrypt; Python and Node have both (hashlib.pbkdf2_hmac, crypto.pbkdf2Sync). None of the four has argon2id or bcrypt built in.

Exercise

Changing the password

Solve it in your editor.

The buyer's account has the password "qwerty". Write changePassword(account, oldPassword, newPassword), which:

  1. checks the current password with verify and refuses if it is wrong;
  2. refuses a new password shorter than 12 characters;
  3. refuses a new password that (ignoring upper and lower case) is in the list password1234, qwerty123456, 123456789012;
  4. otherwise stores a new record with a new salt and returns "Password changed".

Call it with ("qwerty1", "new-pass-2026!"), ("qwerty", "short1"), ("qwerty", "Qwerty123456") and ("qwerty", "tram-9-to-vitosha"), then check the login with the new password:

Output
Wrong current password
The new password is shorter than 12 characters
This password is on the lists of leaked passwords
Password changed
Log in with the new password: true

More tasks

  • A new hash at login. Add login from "The cost grows over the years" and check that an old account with a lower cost comes out of the login with the new one.
  • The same time for a wrong email. If the email does not exist, the login answers at once; if it does, it computes the slow hash. How would you make both cases take the same time?

Test

Check yourself

1. Why are passwords not stored encrypted with a key?
2. What does a salt do, and what does it NOT do?
3. Why does a slow hash help rather than hurt?
4. Why is the hash compared in constant time at login?
5. You raise the cost from 10,000 to 20,000 iterations. What happens to the old records?

Made by Ivo Gergov as part of Evo Tech's campaign for accessible programming education ·