---
title: 'Password Storage'
source: 'https://academia.sh/en/courses/authentication-and-authorization/password-storage'
course: 'Authentication and Authorization'
language: en
updated: '2026-08-19T05:19:33+00:00'
license: 'CC BY-SA 4.0'
---

# 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.

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.

```bash
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
```

```text
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.

```bash
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
```

```text
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.

```bash
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
```

```text
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.

```bash
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
```

```text
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.
