Design a password hashing module with upgrades

Design the module that stores and checks passwords for years: Argon2id with explicit parameters, the PHC string format, a pepper applied with HMAC, rehash on login when parameters or peppers change, wrapping legacy SHA-1 hashes on day one and draining bcrypt over time, equal timing for unknown users and a concurrency gate so memory-hard hashing cannot exhaust the box.

● Must know for interviews● MediumFREEDesign problem·24 min read·Intermediate → Advanced
Argon2idpassword hashingPHC string formatpepperrehash on loginbcrypt migration

01Understand the problem

✓What you will learn
  • Choose Argon2id parameters from published guidance and your latency budget
  • Store hashes in the PHC string format so parameters travel with the hash
  • Add a pepper with HMAC and rotate it without a password reset
  • Migrate legacy hashes safely: wrap weak ones now, rehash everything on the next login
◎Interview radar

What they probe: why a fast hash is wrong, what Argon2id's three parameters cost an attacker, where the pepper lives, how you upgrade parameters without knowing passwords, and how you avoid user enumeration by timing. Where candidates slip: SHA-256 with a salt, a pepper stored in the same database, rehashing everyone in a batch job (impossible without passwords), unbounded concurrent hashing at 64 MiB each.

Argon2id is a memory-hard password hash (RFC 9106, winner of the Password Hashing Competition). Memory-hard means each guess needs a large block of RAM, which makes GPU and ASIC farms far less efficient than they are against SHA-256 or even bcrypt. The id variant mixes data-independent passes (resisting side channels) with data-dependent ones (resisting time-memory trade-offs).

  1. hash(password) returns a stored record: scheme, pepper id and PHC string.
  2. verify(login, password) returns ok and a user id, or not ok, in roughly the same time whether or not the user exists.
  3. When a stored hash uses old parameters, an old pepper or a legacy scheme, a successful login rewrites it, unless the row changed meanwhile.
  4. Legacy SHA-1 rows are wrapped in Argon2id by a one-off batch; bcrypt rows are verified as-is and upgraded on login.
  5. No more than N hashes run at once; excess requests queue briefly, then fail fast.
SourceArgon2id settingMemory per hash
OWASP Password Storage Cheat Sheet (minimum)m=19456 KiB (19 MiB), t=2, p=119 MiB
RFC 9106 second recommended optionm=65536 KiB (64 MiB), t=3, p=464 MiB
RFC 9106 first recommended optionm=2 GiB, t=1, p=42 GiB (rarely practical on a web tier)
This lesson's defaultm=65536, t=3, p=4, 16-byte salt, 32-byte tag64 MiB; about 100 ms on a modern core

02Interfaces first

The contractts
export interface Argon2Params { m: number; t: number; p: number }   // memory in KiB, passes, lanes
export interface Kdf { argon2id(input: Buffer, salt: Buffer, params: Argon2Params, outLen: number): Promise<Buffer> }
export interface Bcrypt { compare(password: string, hash: string): Promise<boolean> }

export interface Pepper { id: string; key: Buffer }               // from a secrets manager or HSM, never the user database
export interface PepperRing { current: Pepper; byId(id: string): Pepper | undefined }

export type Stored =
  | { scheme: "argon2id"; pepperId: string; phc: string }
  | { scheme: "sha1-wrapped"; pepperId: string; legacySalt: string; phc: string }   // argon2id over hex(sha1(salt + password))
  | { scheme: "bcrypt"; hash: string };                                             // legacy, upgraded on login

export interface CredentialRow { userId: string; stored: Stored; version: number }
export interface CredentialStore {
  find(login: string): Promise<CredentialRow | undefined>;
  /** Write only if the row still has this version, so a password change in between wins. */
  replaceIf(userId: string, version: number, next: Stored): Promise<boolean>;
}

export type VerifyOutcome = { ok: false } | { ok: true; userId: string; upgraded: boolean };
PHC string anatomy (illustrative values)text
$argon2id$v=19$m=65536,t=3,p=4$c29tZXNhbHRzb21lc2FsdA$Rdescudvp7d2PKJ7l4dg8mJm5fP0KZvZq9cJ1dX0R6E
 |         |    |                 |                      |
 algorithm  ver  parameters        salt (B64, no padding)  tag (B64, no padding)
 v=19 is Argon2 version 1.3 (0x13). 16-byte salt = 22 characters; 32-byte tag = 43 characters.
iWhy the pepper is an HMAC, not a concatenation

HMAC-SHA256(pepper, password) gives a fixed 32-byte input whatever the password length, cannot be confused with a salt, and keeps the pepper out of the stored string. Store the pepper id with the hash so you can rotate: new hashes use the new pepper, old ones verify with the old one and are rewritten on login.

03PHC encoding, the hasher and the gate

PHC and concurrency gatets
import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";

const b64 = (b: Buffer) => b.toString("base64").replace(/=+$/, "");
const PHC_RE = /^\$argon2id\$v=19\$m=(\d{1,7}),t=(\d{1,2}),p=(\d{1,2})\$([A-Za-z0-9+/]{22,86})\$([A-Za-z0-9+/]{43,86})$/;

export function assertParams(p: Argon2Params): void {
  if (![p.m, p.t, p.p].every(Number.isInteger)) throw new Error("argon2 parameters must be integers");
  if (p.p < 1 || p.p > 16) throw new Error("p must be in [1, 16]");
  if (p.t < 1 || p.t > 10) throw new Error("t must be in [1, 10]");
  if (p.m < 8 * p.p || p.m > 1_048_576) throw new Error("m must be at least 8p KiB and at most 1 GiB");
}

export function encodePhc(p: Argon2Params, salt: Buffer, tag: Buffer): string {
  return "$argon2id$v=19$m=" + p.m + ",t=" + p.t + ",p=" + p.p + "$" + b64(salt) + "$" + b64(tag);
}

export function parsePhc(s: string): { params: Argon2Params; salt: Buffer; tag: Buffer } {
  const m = PHC_RE.exec(s);
  if (!m) throw new Error("not an argon2id v19 PHC string");
  const params = { m: Number(m[1]), t: Number(m[2]), p: Number(m[3]) };
  assertParams(params);                                   // a tampered row must not ask for 9 GB of RAM
  return { params, salt: Buffer.from(m[4], "base64"), tag: Buffer.from(m[5], "base64") };
}

export class BusyError extends Error {}

/** Bounded concurrency with a bounded queue: 64 MiB per hash means 50 parallel logins would want 3.2 GiB. */
export class Semaphore {
  private active = 0;
  private readonly waiters: (() => void)[] = [];
  constructor(private readonly max: number, private readonly maxQueue: number) {
    if (!(Number.isInteger(max) && max > 0 && Number.isInteger(maxQueue) && maxQueue >= 0)) throw new Error("invalid limits");
  }
  async run<T>(fn: () => Promise<T>): Promise<T> {
    if (this.active < this.max) this.active++;
    else if (this.waiters.length >= this.maxQueue) throw new BusyError("hashing capacity exhausted");
    else await new Promise<void>((resolve) => this.waiters.push(resolve));   // the releaser hands its slot over
    try { return await fn(); }
    finally {
      const next = this.waiters.shift();
      if (next) next(); else this.active--;
    }
  }
}
The hasherts
const TAG_LEN = 32;
const MAX_PASSWORD_CHARS = 1024;

export class PasswordHasher {
  private dummy?: Promise<Stored>;
  constructor(private readonly params: Argon2Params, private readonly peppers: PepperRing, private readonly kdf: Kdf,
              private readonly bcrypt: Bcrypt, private readonly store: CredentialStore, private readonly gate: Semaphore) {
    assertParams(params);
  }

  /** NFKC so the same password typed on two keyboards matches; HMAC so a database dump without the pepper is useless. */
  private input(material: string, pepper: Pepper): Buffer {
    return createHmac("sha256", pepper.key).update(material.normalize("NFKC"), "utf8").digest();
  }

  private derive(input: Buffer, salt: Buffer, params: Argon2Params, len: number): Promise<Buffer> {
    return this.gate.run(() => this.kdf.argon2id(input, salt, params, len));
  }

  /** Length policy (minimums, breached-password checks) belongs to set-password, not here: legacy passwords must still rehash. */
  async hash(password: string): Promise<Stored> {
    if (password.length === 0 || password.length > MAX_PASSWORD_CHARS) throw new RangeError("password length out of range");
    const pepper = this.peppers.current;
    const salt = randomBytes(16);
    const tag = await this.derive(this.input(password, pepper), salt, this.params, TAG_LEN);
    return { scheme: "argon2id", pepperId: pepper.id, phc: encodePhc(this.params, salt, tag) };
  }

  /** One-off batch over legacy rows: no password needed, and the weak SHA-1 values leave the database today. */
  async wrapLegacySha1(legacySalt: string, sha1Hex: string): Promise<Stored> {
    if (!/^[0-9a-fA-F]{40}$/.test(sha1Hex)) throw new Error("not a SHA-1 hex digest");
    const pepper = this.peppers.current;
    const salt = randomBytes(16);
    const tag = await this.derive(this.input(sha1Hex.toLowerCase(), pepper), salt, this.params, TAG_LEN);
    return { scheme: "sha1-wrapped", pepperId: pepper.id, legacySalt, phc: encodePhc(this.params, salt, tag) };
  }

  async verify(login: string, password: string): Promise<VerifyOutcome> {
    if (password.length === 0 || password.length > MAX_PASSWORD_CHARS) return { ok: false };
    const row = await this.store.find(login);
    if (!row) {
      // Spend the same Argon2 work as a real check so response time does not reveal which logins exist.
      await this.matches(await (this.dummy ??= this.hash(randomBytes(16).toString("hex"))), password);
      return { ok: false };
    }
    if (!(await this.matches(row.stored, password))) return { ok: false };
    if (!this.needsRehash(row.stored)) return { ok: true, userId: row.userId, upgraded: false };
    const upgraded = await this.store.replaceIf(row.userId, row.version, await this.hash(password));
    return { ok: true, userId: row.userId, upgraded };
  }

  needsRehash(s: Stored): boolean {
    if (s.scheme !== "argon2id" || s.pepperId !== this.peppers.current.id) return true;
    const { params } = parsePhc(s.phc);
    return params.m !== this.params.m || params.t !== this.params.t || params.p !== this.params.p;
  }

  private matches(s: Stored, password: string): Promise<boolean> {
    switch (s.scheme) {
      case "argon2id": return this.checkPhc(s.phc, s.pepperId, password);
      case "sha1-wrapped": return this.checkPhc(s.phc, s.pepperId, createHash("sha1").update(s.legacySalt + password, "utf8").digest("hex"));
      case "bcrypt": return this.gate.run(() => this.bcrypt.compare(password, s.hash));   // raw password: the legacy input rules
    }
  }

  private async checkPhc(phc: string, pepperId: string, material: string): Promise<boolean> {
    const pepper = this.peppers.byId(pepperId);
    if (!pepper) throw new Error("pepper " + pepperId + " is missing from the ring");     // configuration error, not a wrong password
    const { params, salt, tag } = parsePhc(phc);
    const out = await this.derive(this.input(material, pepper), salt, params, tag.length);
    return timingSafeEqual(out, tag);
  }
}
Production KDF adapterts
import { argon2 } from "node:crypto";   // Node 24.7+; on older Node use the argon2 npm package with raw: true

export const nodeKdf: Kdf = {
  argon2id: (input, salt, p, outLen) => new Promise((resolve, reject) =>
    argon2("argon2id", { message: input, nonce: salt, memory: p.m, passes: p.t, parallelism: p.p, tagLength: outLen },
      (err, tag) => (err ? reject(err) : resolve(tag)))),
};

04Upgrades, timing, failure modes and tests

SituationWhat happens on the next successful login
Parameters raised from m=19456,t=2 to m=65536,t=3Verify with the old parameters (they are in the PHC string), then rewrite with the new ones
Pepper rotated from p1 to p2p1 stays in the ring; verify with p1, rewrite with p2. Retire p1 once no rows reference it
sha1-wrapped rowCompute sha1 hex, verify the Argon2id wrapper, rewrite as plain argon2id
bcrypt rowbcrypt.compare with the raw password, rewrite as argon2id
User changed password between read and rewritereplaceIf sees a new version and refuses; the newer hash stays
Users who never log in againThey stay on the old scheme. After a deadline, wrap or force a reset; never leave raw SHA-1
!Changing the input rules breaks old hashes

NFKC, the pepper HMAC and the length cap are part of the hash. Legacy schemes were computed over the raw password, so verify them with their original rules and only apply the new rules when you rehash. bcrypt also ignores everything after 72 bytes, which is one more reason to move away from it.

vitestts
import { describe, it, expect } from "vitest";

// Fast deterministic stand-in for Argon2id: tests check the module's logic, not the KDF.
let kdfCalls = 0;
const fakeKdf: Kdf = {
  argon2id: async (input, salt, p, len) => {
    kdfCalls++;
    return createHash("sha512").update(JSON.stringify(p)).update(salt).update(input).digest().subarray(0, len);
  },
};
const fakeBcrypt: Bcrypt = { compare: async (pw, h) => h === "bcrypt:" + pw };

class MemCreds implements CredentialStore {
  rows = new Map<string, CredentialRow>();
  async find(login: string) { const r = this.rows.get(login); return r && { ...r }; }
  async replaceIf(userId: string, version: number, next: Stored) {
    const r = this.rows.get(userId);
    if (!r || r.version !== version) return false;
    this.rows.set(userId, { userId, stored: next, version: version + 1 });
    return true;
  }
}

const p1 = { id: "p1", key: Buffer.alloc(32, 1) };
const p2 = { id: "p2", key: Buffer.alloc(32, 2) };
const ring = (current: Pepper): PepperRing => ({ current, byId: (id) => [p1, p2].find((x) => x.id === id) });
const OLD = { m: 19456, t: 2, p: 1 };
const NEW = { m: 65536, t: 3, p: 4 };
const make = (params: Argon2Params, pep: Pepper, store: MemCreds) => new PasswordHasher(params, ring(pep), fakeKdf, fakeBcrypt, store, new Semaphore(4, 100));

describe("PasswordHasher", () => {
  it("verifies the right password and refuses a wrong one", async () => {
    const store = new MemCreds();
    const h = make(NEW, p1, store);
    store.rows.set("ana", { userId: "ana", stored: await h.hash("correct horse"), version: 1 });
    expect((await h.verify("ana", "correct horse")).ok).toBe(true);
    expect((await h.verify("ana", "correct hose")).ok).toBe(false);
  });
  it("normalises with NFKC, so a ligature matches its letters", async () => {
    const store = new MemCreds();
    const h = make(NEW, p1, store);
    store.rows.set("ana", { userId: "ana", stored: await h.hash("fish-tank-42"), version: 1 });   // U+FB01 is the "fi" ligature
    expect((await h.verify("ana", "fish-tank-42")).ok).toBe(true);
  });
  it("rehashes on login after a parameter bump and a pepper rotation", async () => {
    const store = new MemCreds();
    store.rows.set("ana", { userId: "ana", stored: await make(OLD, p1, store).hash("pw-1"), version: 1 });
    const r = await make(NEW, p2, store).verify("ana", "pw-1");
    expect(r).toEqual({ ok: true, userId: "ana", upgraded: true });
    const s = store.rows.get("ana")!.stored;
    expect(s.scheme === "argon2id" && s.pepperId === "p2" && s.phc.includes("m=65536,t=3,p=4")).toBe(true);
  });
  it("upgrades a wrapped SHA-1 row to plain argon2id", async () => {
    const store = new MemCreds();
    const h = make(NEW, p1, store);
    const legacy = createHash("sha1").update("s4lt" + "hunter2", "utf8").digest("hex");
    store.rows.set("bob", { userId: "bob", stored: await h.wrapLegacySha1("s4lt", legacy), version: 1 });
    expect((await h.verify("bob", "hunter2")).ok).toBe(true);
    expect(store.rows.get("bob")!.stored.scheme).toBe("argon2id");
  });
  it("spends KDF work for unknown users", async () => {
    const h = make(NEW, p1, new MemCreds());
    await h.verify("ghost", "x");                       // first call also builds the dummy hash
    const before = kdfCalls;
    await h.verify("ghost", "x");
    expect(kdfCalls - before).toBe(1);
  });
  it("does not overwrite a concurrent password change", async () => {
    const store = new MemCreds();
    store.rows.set("ana", { userId: "ana", stored: { scheme: "bcrypt", hash: "bcrypt:old-pw" }, version: 1 });
    const h = make(NEW, p1, store);
    const realFind = store.find.bind(store);
    store.find = async (login) => {                     // simulate a password change landing between read and rewrite
      const row = await realFind(login);
      store.rows.set("ana", { userId: "ana", stored: await h.hash("new-pw"), version: 2 });
      return row;
    };
    expect(await h.verify("ana", "old-pw")).toEqual({ ok: true, userId: "ana", upgraded: false });
    expect(store.rows.get("ana")!.version).toBe(2);
  });
});

05Extensions, follow-ups, how to say it

  • Tuning: benchmark on production hardware; pick the largest m that keeps p95 login under your budget (often 100 to 250 ms) with the gate's concurrency.
  • Pepper in an HSM: compute the HMAC inside KMS or an HSM so the pepper never enters process memory; it costs a network call per login.
  • Breached passwords: check new passwords against a k-anonymity range API at set time, not at login.
  • Offload: under credential stuffing, a dedicated hashing pool protects the rest of the service; the BusyError path should return 503, not 401.
Q1Why can't you upgrade every hash in a nightly batch?+

You do not have the passwords. You can only rehash when the user supplies one, or wrap the old hash inside a new one, which is what the SHA-1 wrapping does.

Q2What does the pepper buy if the salt is already random?+

The salt stops precomputation and makes each hash unique. The pepper means a database dump alone cannot be cracked at all, because a secret input lives somewhere else.

Q3How do you stop timing from revealing which logins exist?+

Run a full Argon2 verification against a dummy hash with current parameters when the user is missing. Legacy-scheme users can still differ in timing, which reveals the scheme, not existence.

Q4Why bound the queue as well as concurrency?+

An unbounded queue turns a credential-stuffing burst into unbounded memory and latency. A short queue plus fast 503s keeps the service up and makes the attack visible.

“How to say it in the room

"Passwords go through NFKC, then HMAC-SHA256 with a pepper held in a secrets manager, then Argon2id at 64 MiB, three passes, four lanes, stored as a PHC string so the parameters travel with the hash. On every successful login I check whether the scheme, pepper or parameters are stale and rewrite with compare-and-set so a concurrent password change wins. SHA-1 rows get wrapped in Argon2id on day one without needing passwords; bcrypt drains on login. Unknown users burn a dummy hash for equal timing, and a semaphore with a short queue keeps memory-hard hashing from taking the box down."

Test yourself

You raise Argon2id from m=19456,t=2 to m=65536,t=3. How do existing users' hashes change?

Finished this one?
Tick it and it shows as done on the roadmap and the course.