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) andpin_salt(base64) onstaff
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
- Security model
- Slice 1 — Identity & Audit
- ADR-0026: Google OAuth + device sessions — Layer 1 above PIN