Lesson 12 / 23
Password Storage
Storing a password irreversibly: the work and memory factors of slow hashing algorithms, choosing parameters against a time budget, the shape of the stored record, and comparing it in constant time.
Contents
The previous topic covered how identity gets verified: the basic scheme, sessions, tokens, and multi-factor authentication were all the server’s answers to one question — does this request really come from that person? A single assumption sat under all of them: the server can verify something the user knows. This topic looks at that assumption itself — what does the server store to verify the password?
In the library lending service, members and staff sign in with a password. The record on the server can end up in someone else’s hands one day — a backup file, a botched export, a compromised reporting account. That is the measure of password storage: when the record leaks, how much information leaks with it? This lesson is about lowering the cost of that moment.
Verifiable But Irreversible
The server does not need to know the password to verify it — only to produce the same result from what the user sends. This is why a password is stored not as plain text but as a one-way hash: computing the output from the input is easy, and computing the input back from the output is computationally infeasible.
The hash function concept was introduced in the Data Structures course, where the goal was a bucket number and the measure was speed. In password storage, the measure reverses: the faster a hash computes, the faster a candidate password can be tried against a leaked record. A fast verification helps the attacker; a slow one only delays a login by a few tens of milliseconds. This asymmetry is password storage’s one design lever.
For this reason, a password uses not a general-purpose hash function but a key
derivation function. These carry two adjustable factors: the work factor, how many
rounds the computation takes, and the memory factor, how much memory it holds. A
design that forces memory use is called memory hardness, meant to make spreading the
computation across many small units of work expensive. scrypt and Argon2 are two
members of this class; this lesson uses scrypt, built into the node runtime.
Measuring the Cost
The difference between the two approaches is measured, not argued. The script below
compares, on one machine, the verification time of a single-pass hash against scrypt at
different work factors. The last column is verifications per second on a single core.
cat > measurement.mjs <<'EOF' import { createHash, scryptSync, randomBytes } from "node:crypto"; const password = "shore-branch-2026"; const salt = randomBytes(16); function duration(count, task) { task(); // warm-up const start = process.hrtime.bigint(); for (let i = 0; i < count; i++) task(); return Number(process.hrtime.bigint() - start) / count / 1e6; // milliseconds } const rows = [ ["sha-256 (single pass)", "-", duration(500, () => createHash("sha256").update(salt).update(password).digest())], ]; for (const N of [2 ** 12, 2 ** 14, 2 ** 16, 2 ** 17]) { const r = 8, p = 1; rows.push([ `scrypt N=2^${Math.log2(N)} r=${r} p=${p}`, `${((128 * r * N) / 1024 / 1024).toFixed(0)} MiB`, duration(3, () => scryptSync(password, salt, 32, { N, r, p, maxmem: 512 * 1024 * 1024 })), ]); } console.log("algorithm | memory | verify | logins/s per core"); console.log("-----------------------|---------|------------|------------------------"); for (const [name, memory, ms] of rows) { console.log( name.padEnd(22) + " | " + memory.padStart(7) + " | " + (ms < 1 ? ms.toFixed(4) : ms.toFixed(1)).padStart(7) + " ms | " + (1000 / ms).toFixed(0).padStart(22) ); } EOF node measurement.mjs
algorithm | memory | verify | logins/s per core -----------------------|---------|------------|------------------------ sha-256 (single pass) | - | 0.0013 ms | 781504 scrypt N=2^12 r=8 p=1 | 4 MiB | 4.3 ms | 231 scrypt N=2^14 r=8 p=1 | 16 MiB | 17.6 ms | 57 scrypt N=2^16 r=8 p=1 | 64 MiB | 79.4 ms | 13 scrypt N=2^17 r=8 p=1 | 128 MiB | 159.5 ms | 6
Absolute times change with the machine and runtime version; these were taken on one
desktop core with node 24. What does not change is the order of magnitude: roughly a
fourteen-thousand-fold gap between the single-pass hash and N=2^14. That gap is
directly proportional to an attacker’s cost against a leaked record — hundreds of
thousands of candidates per second with the fast hash, only a few dozen with the slow
one.
The memory column shows the second defense. At N=2^16, each verification holds 64
MiB — trivial for one server, decisive for a party spreading the same work across
thousands of parallel units. Raising the work factor slows the computation; raising the
memory factor makes it impossible to spread across cheap hardware.
Choosing the Work Factor
Parameters are not chosen by “bigger is always better.” Verification is the server’s own work, and too large a factor lets login requests lock it up. The choice has two constraints: the time budget per login and the memory held at peak load.
The script below applies both together: a 100-millisecond budget per login, a peak load of twenty logins per second. The memory column is the combined consumption of every verification running at once at that factor.
cat > calibrate.mjs <<'EOF' import { scryptSync, randomBytes } from "node:crypto"; const BUDGET_MS = 100; // time set aside per login const CONCURRENT_LOGINS = 20; // expected peak logins per second const salt = randomBytes(16), r = 8, p = 1; function measure(N) { const settings = { N, r, p, maxmem: 512 * 1024 * 1024 }; scryptSync("measurement", salt, 32, settings); const start = process.hrtime.bigint(); scryptSync("measurement", salt, 32, settings); return Number(process.hrtime.bigint() - start) / 1e6; } console.log(`budget: ${BUDGET_MS} ms/login, peak load: ${CONCURRENT_LOGINS} logins/s`); console.log("N | verify | memory at peak | budget"); console.log("------|-----------|-------------------|-------"); let chosen = null; for (const exp of [12, 13, 14, 15, 16, 17]) { const N = 2 ** exp, ms = measure(N); const memory = (128 * r * N * Math.ceil(ms / 1000 * CONCURRENT_LOGINS)) / 1024 / 1024; const status = ms <= BUDGET_MS ? "eligible" : "exceeded"; if (ms <= BUDGET_MS) chosen = exp; console.log(`2^${exp} | ${ms.toFixed(1).padStart(6)} ms | ${memory.toFixed(0).padStart(13)} MiB | ${status}`); } console.log("chosen: N=2^" + chosen); EOF node calibrate.mjs
budget: 100 ms/login, peak load: 20 logins/s N | verify | memory at peak | budget ------|-----------|-------------------|------- 2^12 | 5.3 ms | 4 MiB | eligible 2^13 | 8.7 ms | 8 MiB | eligible 2^14 | 18.2 ms | 16 MiB | eligible 2^15 | 37.7 ms | 32 MiB | eligible 2^16 | 77.8 ms | 128 MiB | eligible 2^17 | 162.7 ms | 512 MiB | exceeded chosen: N=2^16
The numbers the script produces are specific to the machine; the decision procedure is not. The rule: pick the largest factor that stays under budget, and read it from the deployment configuration rather than hardcoding it. Re-measure whenever the server hardware changes.
The budget is itself a decision. Signing in is an operation users expect to wait for, and a hundred milliseconds passes unnoticed. The computation is not repeated on every request — only at login, after which a session or token takes over. A setup that repeats verification on every API call makes this budget unworkable; that is why a password hash has no place verifying an API key.
The Shape of the Stored Record
The record holds more than the hash: the algorithm that produced it and its parameters are stored alongside, or upgraded parameters make old records unverifiable. The standard shape joins these fields into one string with a separator.
cat > password.mjs <<'EOF' import { scryptSync, randomBytes, timingSafeEqual } from "node:crypto"; const SETTINGS = { N: 2 ** 14, r: 8, p: 1, length: 32 }; function store(password) { const salt = randomBytes(16); const digest = scryptSync(password.normalize("NFKC"), salt, SETTINGS.length, SETTINGS); return ["scrypt", SETTINGS.N, SETTINGS.r, SETTINGS.p, salt.toString("base64"), digest.toString("base64")].join("$"); } function verify(password, record) { const [alg, N, r, p, salt64, digest64] = record.split("$"); if (alg !== "scrypt") return false; const expected = Buffer.from(digest64, "base64"); const computed = scryptSync(password.normalize("NFKC"), Buffer.from(salt64, "base64"), expected.length, { N: +N, r: +r, p: +p }); return timingSafeEqual(computed, expected); } const record = store("shore-branch-2026"); console.log("stored record :", record); console.log("length :", record.length, "characters"); console.log("correct password:", verify("shore-branch-2026", record)); console.log("wrong password :", verify("shore-branch-2025", record)); console.log("second record :", store("shore-branch-2026").slice(0, 24) + "..."); EOF node password.mjs
stored record : scrypt$16384$8$1$/kFvW5D9IBMpOV7Sn9t0YQ==$JwrU7KOCzQiHUKCE8+Uazs9zrp1RhJgQVcYqhINzCsQ= length : 86 characters correct password: true wrong password : false second record : scrypt$16384$8$1$QVqPrxj...
The randomly generated values change on every run; the record’s length and field layout do not. Three pieces of information travel together — algorithm, parameters, random value — which is why the database column is defined at a width that leaves room for future formats, not a fixed length.
The last line points to the next lesson: the same password stored twice produces
different records. The randomBytes call is why, and the reason is next lesson’s
subject.
The normalize("NFKC") call rests on a separate justification: text that looks identical
can be encoded as different byte sequences, and if registration produces one form while
sign-in produces another, verification fails — to the user, “I typed the right password
and it is not accepted.” Normalization runs the same way on both the storage and
verification paths; applying it to only one moves the problem instead of solving it.
The Time a Comparison Takes
Verification’s last step compares two byte sequences, and how that step is written matters. A comparison that returns early stops at the first differing byte — so its running time gives away how far the candidate overlapped with the correct value.
cat > comparison.mjs <<'EOF' import { randomBytes, timingSafeEqual } from "node:crypto"; const correct = randomBytes(32); function earlyReturn(a, b) { // stops at the first difference if (a.length !== b.length) return false; for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false; return true; } function measure(compare, candidate) { for (let i = 0; i < 2000; i++) compare(correct, candidate); // warm-up const start = process.hrtime.bigint(); for (let i = 0; i < 200000; i++) compare(correct, candidate); return Number(process.hrtime.bigint() - start) / 200000; // nanoseconds } const firstByteDiff = Buffer.from(correct); firstByteDiff[0] ^= 1; const lastByteDiff = Buffer.from(correct); lastByteDiff[31] ^= 1; console.log("comparison | diff at 1st byte | diff at last byte | ratio"); console.log("------------------|--------------------|--------------------|------"); for (const [name, fn] of [["early return", earlyReturn], ["timingSafeEqual", timingSafeEqual]]) { const a = measure(fn, firstByteDiff), b = measure(fn, lastByteDiff); console.log( name.padEnd(17) + " | " + (a.toFixed(1) + " ns").padStart(18) + " | " + (b.toFixed(1) + " ns").padStart(18) + " | " + (b / a).toFixed(2).padStart(5) ); } EOF node comparison.mjs
comparison | diff at 1st byte | diff at last byte | ratio ------------------|--------------------|--------------------|------ early return | 3.4 ns | 22.4 ns | 6.60 timingSafeEqual | 32.9 ns | 30.6 ns | 0.93
Nanosecond values change with the machine; the ratio column does not. With the
early-return comparison, a candidate diverging at the last byte takes six times as long
as one diverging at the first — time as a signal for overlap length. timingSafeEqual
processes every byte regardless, so its ratio stays near one.
For the password itself this signal is hard to exploit remotely — tens of milliseconds of hashing precede the comparison, and network latency buries it in noise. But the same pattern appears where there is no hash: session identifiers, reset tokens, API keys, signature checks. So the rule holds everywhere — every comparison against a secret is constant-time. The Client-Side Security topic set the same rule for comparing request tokens; the server side applies it too.
Upgrading Parameters
Today’s factor grows insufficient as hardware gets cheaper. Because the record carries its own parameters, the upgrade runs without interruption: the user’s submitted password is available at login, and on successful verification the record is regenerated with new parameters and written back — one extra write, invisible to the user.
The limit: accounts that never sign in keep their old parameters. For those, a second path applies the new algorithm on top of the old hash and marks the record as two-layered. Carrying an algorithm field in the record format is what makes this transition possible without losing records.
A third path is not accepted — asking users to resubmit passwords in plain text. A system that can ask for a password again can hold onto it somewhere; this whole lesson establishes the opposite.
Summary
- A password is stored irreversibly; the server needs to produce the same result, not know the password.
- General-purpose hash functions do not fit passwords; key derivation functions with adjustable work and memory factors do.
- The work factor is the largest value that stays under the per-login time budget; memory at peak load is the second constraint.
- The record carries the algorithm and parameters alongside the hash; an upgrade regenerates the record on a successful login.
- A comparison against a secret is constant-time; an early-return comparison’s running time gives away overlap length.
Next Step
This lesson generated a random value with randomBytes for every record, and two
records of the same password came out different. That value’s name and job are not
explained yet. The next lesson shows what the salt is for, why an unsalted setup gives
two members with the same password identical records, and how much a single leak reveals
about how many users. It also covers where the pepper — a second value separate from the
salt — sits, not in the database but in the application’s configuration, and what that
separation changes at the moment of a leak.
To keep your progress and take notes, Log in
My notes
Log in to take notes.