Skip to main content

ADR-0013 — PBKDF2-SHA256 for PIN hashing

  • Status: Accepted — extended by ADR-0026 (PIN is now Layer 2 of a two-layer model)
  • Date: 2026-05-02
  • Decision-makers: Tom Anderson

Context

Staff sign in with a 5-digit PIN. PINs are stored hashed, not plain. The hash function needs to be:

  • Slow enough to defeat brute-force guessing if the database leaks
  • Fast enough not to block the operator at sign-in (sub-100ms target)
  • Available in the Workers runtime (Web Crypto API)
  • Well-trodden so we're not the first to find a bug in it

Options:

  • bcrypt — well-trodden, requires a JS implementation (no native Workers support), slower than necessary
  • scrypt — memory-hard, good against GPU attacks, slower at scale
  • Argon2 — modern choice, memory-hard, but no native Web Crypto support; would require WASM
  • PBKDF2-SHA256 — native Web Crypto support, well-trodden, configurable iterations

The 5-digit PIN keyspace is only 100,000 — brute-forceable in seconds without rate limiting regardless of hash. The hash protects against database leakage; rate limiting (covered in security model) protects against online brute-force.

Decision

Use PBKDF2-SHA256 via the Workers Web Crypto API:

// Pseudo
async function hashPin(pin, salt) {
const enc = new TextEncoder();
const key = await crypto.subtle.importKey('raw', enc.encode(pin), 'PBKDF2', false, ['deriveBits']);
const bits = await crypto.subtle.deriveBits(
{ name: 'PBKDF2', salt: enc.encode(salt), iterations: 100000, hash: 'SHA-256' },
key, 256
);
return Buffer.from(bits).toString('base64');
}
  • 100,000 iterations (~30-50ms on Workers, acceptable for sign-in)
  • 16-byte random salt per staff row
  • Stored as pin_hash (base64) and pin_salt (base64) on staff

Consequences

Positive:

  • Native to the runtime; no dependencies
  • Well-vetted algorithm; FIPS-approved
  • Configurable iteration count if hardware gets faster
  • 30-50ms sign-in latency is acceptable

Negative:

  • Not as memory-hard as scrypt or Argon2 — better defense if GPU brute-force at scale becomes a concern
  • 5-digit PIN keyspace dominates the security; the hash is a secondary defense

Mitigations:

  • Lockout after 5 wrong attempts in 60 seconds (in security model) is the primary defense
  • If the database leaks (worst case), rotating salts + bumping iteration counts via a re-hash on next login is straightforward
  • If we ever decide Argon2 is necessary, the migration is "rehash on next valid sign-in" — gradual, no flag day

Notes

The 100k iteration count is conservative for SHA-256 on modern hardware. If Workers' CPU budget tightens we could drop to 50k with minimal practical security loss.

PIN as Layer 2 (2026-05-13 update)

When this ADR was written, the PIN was the entire auth surface and the rate limit was the security boundary. With ADR-0026 shipped, the PIN is now Layer 2 of a two-layer model — Layer 1 is Google OAuth + a 30-day device_sessions cookie that gates the browser before the PIN UI is ever shown. This dramatically reduces the PIN's load-bearing role: an attacker can't even reach the PIN screen without an email on the per-shop allowlist.

The PIN hashing semantics are unchanged. What changed is the context the PIN runs in.

See also