Skip to content
academia.sh

Lesson 13 / 23

Salt and Pepper

What a per-record random salt provides against precomputed tables, the information an unsalted setup leaks, the separate jobs of salt and pepper, and what keeping the pepper outside the database changes at the moment of a leak.

Contents

The previous lesson generated a sixteen-byte value with randomBytes for every record, and two records of the same password came out different. That value’s name is salt, and its job is not to hide the hash but to break equality between records. This lesson covers exactly what the salt prevents, what an unsalted setup gives away, and where a second value separate from the salt — the pepper — sits.

The question follows from where the last lesson left off: a slow hash was chosen, parameters were tuned by measurement, the record format was set. If the records leak, does each one have to be attacked separately, or does a single piece of work serve them all at once? The salt is what separates these two outcomes.

The Salt’s Job

A hash function is deterministic: the same input always produces the same output. This property is what makes verification possible, but used alone it has an unwanted consequence — two members who pick the same password end up with identical records.

The salt is a second input to the hash computation: generated separately for each record, not secret, stored next to the hash. The result — the same password with different salts produces different hashes. Verification reads the salt from the record, combines it with the submitted password, and compares the result; the flow does not change, only the relationship between records does.

What an Unsalted Setup Reveals

The script below builds the library’s member table with two columns: unsalted and salted hashes of the same passwords. The data is local and for illustration only; three of eight members share one password, and two more pairs share two other passwords.

cat > salt.mjs <<'EOF'
import { createHash, randomBytes, scryptSync } from "node:crypto";
import { DatabaseSync } from "node:sqlite";

const db = new DatabaseSync(":memory:");
db.exec(`CREATE TABLE member(code TEXT PRIMARY KEY, branch TEXT, unsalted TEXT, salted TEXT)`);

// Local sample data; only to show the setup difference.
const members = [
  ["U-1001", "S-01", "sea-shell-7"],
  ["U-1002", "S-01", "library"],
  ["U-1003", "S-02", "sea-shell-7"],
  ["U-1004", "S-02", "library"],
  ["U-1005", "S-01", "summer-2026-reading"],
  ["U-1006", "S-03", "library"],
  ["U-1007", "S-03", "plane-tree"],
  ["U-1008", "S-02", "summer-2026-reading"],
];

const settings = { N: 2 ** 12, r: 8, p: 1 };
const insert = db.prepare("INSERT INTO member VALUES (?,?,?,?)");
for (const [code, branch, password] of members) {
  const unsalted = createHash("sha256").update(password).digest("base64");
  const salt = randomBytes(16);
  const salted = salt.toString("base64") + "$" +
    scryptSync(password, salt, 32, settings).toString("base64");
  insert.run(code, branch, unsalted, salted);
}

const print = (title, rows) => {
  console.log(title);
  for (const r of rows) console.log("  " + r);
};

print("unsalted records (first 12 characters):",
  db.prepare("SELECT code, substr(unsalted,1,12) k FROM member ORDER BY code").all()
    .map((s) => `${s.code}  ${s.k}`));

print("\nmembers sharing the same hash:",
  db.prepare(`SELECT substr(unsalted,1,12) k, COUNT(*) count, group_concat(code,' ') who
              FROM member GROUP BY unsalted HAVING COUNT(*) > 1`).all()
    .map((s) => `${s.k}  ${s.count} members: ${s.who}`));

print("\nsame grouping on the salted records:",
  db.prepare(`SELECT COUNT(*) groups FROM
              (SELECT salted FROM member GROUP BY salted HAVING COUNT(*) > 1)`).all()
    .map((s) => `matching groups: ${s.groups}`));
EOF
node salt.mjs
unsalted records (first 12 characters):
  U-1001  d7y3WIa1ljec
  U-1002  txjxNU9yRzEu
  U-1003  d7y3WIa1ljec
  U-1004  txjxNU9yRzEu
  U-1005  Cp++5Jrvtvu7
  U-1006  txjxNU9yRzEu
  U-1007  gNrOL2tZWWn6
  U-1008  Cp++5Jrvtvu7

members sharing the same hash:
  Cp++5Jrvtvu7  2 members: U-1005 U-1008
  d7y3WIa1ljec  2 members: U-1001 U-1003
  txjxNU9yRzEu  3 members: U-1002 U-1004 U-1006

same grouping on the salted records:
  matching groups: 0

A single GROUP BY query produced three pieces of information without decoding a single password.

First, which members share a password — U-1002, U-1004, and U-1006 picked the same string; these three accounts now act as one, since anything learned about one holds for the other two.

Second, which group is largest: the three-member group points to the most common choice in this table, the choice sitting at the top of any candidate list.

Third, whether a manager’s or staff member’s password matches a member’s — a link between accounts, visible to anyone looking at the table.

The same query against the salted column returns zero groups. The passwords did not change; the records no longer inform on each other. Salt does not strengthen a password — it erases the visible relationship between passwords.

The Cost of a Precomputed Table

The salt’s second, better-known effect works against precomputed tables. Once candidate passwords’ hashes are computed and stored, attacking a leaked unsalted record reduces to a lookup — the computation is already done. A space-compressed form of these tables is called a rainbow table; for the defense, the two amount to the same thing.

Salt breaks this economy. Because the salt differs per record, a precomputed table matches no record; the work has to be redone from scratch for every one. The script below turns this difference into numbers using the times measured in the previous lesson.

cat > cost.mjs <<'EOF'
const CANDIDATES = 1_000_000;    // number of candidates in the precomputed list
const USERS = 100_000;           // number of leaked records
const FAST_MS = 0.0013;          // measured: sha-256 single pass
const SLOW_MS = 18.2;            // measured: scrypt N=2^14

function format(ms) {
  const s = ms / 1000;
  if (s < 3600) return `${s.toFixed(1)} seconds`;
  const h = s / 3600;
  if (h < 24 * 365) return `${h.toFixed(1)} hours`;
  return `${(h / 24 / 365).toFixed(1)} years`;
}

const setups = [
  ["unsalted + fast hash", CANDIDATES * FAST_MS, "list built once, fits every record"],
  ["unsalted + slow hash", CANDIDATES * SLOW_MS, "list built once, fits every record"],
  ["salted   + fast hash", CANDIDATES * USERS * FAST_MS, "work redone per record"],
  ["salted   + slow hash", CANDIDATES * USERS * SLOW_MS, "work redone per record"],
];

console.log(`candidates: ${CANDIDATES.toLocaleString("en-US")}   leaked records: ${USERS.toLocaleString("en-US")}`);
console.log("setup".padEnd(20) + " | work on one core | note");
console.log("-".repeat(21) + "|" + "-".repeat(19) + "|" + "-".repeat(39));
for (const [name, ms, note] of setups) {
  console.log(name.padEnd(20) + " | " + format(ms).padStart(17) + " | " + note);
}
EOF
node cost.mjs
candidates: 1,000,000   leaked records: 100,000
setup                | work on one core | note
---------------------|-------------------|---------------------------------------
unsalted + fast hash |       1.3 seconds | list built once, fits every record
unsalted + slow hash |         5.1 hours | list built once, fits every record
salted   + fast hash |        36.1 hours | work redone per record
salted   + slow hash |        57.7 years | work redone per record

The table shows how the two defenses multiply each other. A slow hash alone is beaten in five hours in an unsalted setup, because the work is done once and applied to all hundred thousand records. Salt alone, with a fast hash, pushes the cost to thirty-six hours. Combined, the number reaches fifty-seven years.

These absolute numbers are not a guess but a product of measured times, and they change with the hardware. What does not change is the shape of the multiplication: salt multiplies the work by the record count; a slow hash multiplies it by unit cost. The two solve separate problems, and neither substitutes for the other.

The Rules of Salt

A correct salt setup is defined by four rules.

Generated separately per record. Using a single salt application-wide leaves the unsalted setup’s equality problem exactly as it was — two records with the same password still produce the same hash.

Generated randomly. A salt derived from a member code, an email address, or the registration time is predictable, which makes precomputation possible again. The source is a cryptographic random number generator.

Long enough. Sixteen bytes is the standard choice. The measure is keeping the probability of two salts colliding negligible; a short salt lets different records land on the same salt, bringing equality back between them.

Not secret. The salt sits next to the hash, in the same row — it does not need to be kept secret; its job is variety, not secrecy. Treating it as secret is the common confusion that leads to using the pepper in its place.

The last rule also governs the password-change flow: when the password changes, the salt is renewed too. Keeping the old salt would leave the new hash comparable to the old one.

Pepper: The Value Outside the Database

Pepper differs from salt on three points: shared by all records, secret, and never in the database. It lives in the application’s configuration — an environment variable or a secrets store. The Server-Side Fundamentals course established separating secrets from code and data paths; pepper is that separation applied to passwords.

It is implemented as an HMAC: the slow hash’s output is hashed once more, keyed with the pepper. What gets written to the record is this final output.

cat > pepper.mjs <<'EOF'
import { scryptSync, randomBytes, createHmac, timingSafeEqual } from "node:crypto";

const SETTINGS = { N: 2 ** 12, r: 8, p: 1 };
const PEPPERS = { v1: process.env.PEPPER_V1 ?? "" };     // read from an environment variable

function store(password, version = "v1") {
  const salt = randomBytes(16);
  const slow = scryptSync(password, salt, 32, SETTINGS);
  const digest = createHmac("sha256", PEPPERS[version]).update(slow).digest();
  return [version, salt.toString("base64"), digest.toString("base64")].join("$");
}

function verify(password, record) {
  const [version, salt64, digest64] = record.split("$");
  const pepper = PEPPERS[version];
  if (pepper === undefined) return "pepper not found";
  const slow = scryptSync(password, Buffer.from(salt64, "base64"), 32, SETTINGS);
  const computed = createHmac("sha256", pepper).update(slow).digest();
  return timingSafeEqual(computed, Buffer.from(digest64, "base64"));
}

const record = store("sea-shell-7");
console.log("written to the database      :", record);
console.log("is the pepper in the record  :", record.includes(PEPPERS.v1));
console.log("right pepper, right password :", verify("sea-shell-7", record));
console.log("right pepper, wrong password :", verify("sea-shell-8", record));

PEPPERS.v1 = "different-value";                        // pepper is unknown
console.log("wrong pepper, right password :", verify("sea-shell-7", record));
EOF
PEPPER_V1='example-app-secret' node pepper.mjs
written to the database      : v1$MC4YxndY4mseXeOMqKekhg==$uoLNUQqNsMoW0YdPUR0xtC1IQ7c/uPATFpw9xbZPx3Y=
is the pepper in the record  : false
right pepper, right password : true
right pepper, wrong password : false
wrong pepper, right password : false

The salt field changes on every run; the meaning of the lines does not. The second line confirms the pepper never enters the record in any form; the last shows its function — even with the correct password, verification comes back negative when the pepper is unknown.

That is what it means at the moment of a leak: if only the database leaks — a backup file, a read-only reporting connection, a botched export — trying candidates against the records gets nowhere, because the key needed for the last step of the computation is not there. The pepper adds exactly this one scenario on top of the salt and the slow hash.

Pepper’s Limits and Rotation

The pepper’s protection has to stay narrowly defined. If the application server is also compromised, the pepper goes with it, and the setup falls back to the level of one without a pepper — which is why it does not replace the salt or the slow hash, only sits on top of both.

The second limit is operational: if the pepper is lost, no user can sign in and there is no way back from the records — the only way out is resetting every password. This makes it the most carefully backed-up piece of the application configuration, and it never appears in logs, error messages, or diagnostic output.

Third is rotation. The record’s v1 field exists for this: when a new pepper is added, the old version stays in the configuration for verification, and the record regenerates with the new version on every successful login — the same procedure as the previous lesson’s parameter upgrade, since in both cases the record carries its own production conditions.

Summary

  • Salt is a value generated per record, not secret, and stored next to the hash; its job is to break equality between records.
  • In an unsalted setup, a single grouping query reveals the accounts sharing a password and the most common choice, without decoding any password.
  • Salt multiplies the cost of a precomputed table by the record count; a slow hash raises the unit cost. The two solve separate problems.
  • Salt is generated per record, randomly, and at sufficient length; it is renewed when the password changes.
  • Pepper is a key shared by all records, secret, and kept outside the database; it protects only the case where the database alone leaks, and it is rotated through a version field.

Next Step

Every defense so far sat on the server side and never touched the password the user chose. Yet what the grouping query revealed — three members picking the same string — cannot be fixed by any server-side parameter. The next lesson turns to password policies: it computes, through a model, how length and complexity rules reshape user behavior, and shows how to check against a breached password list without exposing the list or the password.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close