ADR-0031: Two-Pass PBKDF2 for Free-Tier Password Security
Status: proposed
Date: 2026-07-28
Deciders: @barateza
Relates to: Security audit findings H-1, H-2
Context
The Workers free tier imposes a 10ms CPU budget per request. The current server-side password hashing uses PBKDF2 at 100,000 iterations (~8ms), while the client already uses 600,000 iterations for offline IndexedDB-stored passwords. Argon2id via WASM was evaluated but requires 150ms+ even with minimal parameters — it cannot complete within the free tier budget.
The goal is to strengthen the effective iteration count of passwords stored in D1 without paying for the Workers Bundled plan ($5/mo).
Decision
Two-pass PBKDF2: the client pre-hashes the plaintext password at 600k iterations before sending it to the server. The server re-hashes the received value at 100k iterations before storage or comparison.
Client: PBKDF2(600k, password) → clientHash (8ms client-side)
Server: PBKDF2(100k, clientHash) → storedHash (8ms server-side)
Attacker cracking storedHash: must run 100k + 600k = 700k iterations per guessStored format
| Format | Meaning |
|---|---|
pbkdf2:<salt>:<hash> | Legacy — server did 100k on raw password |
pbkdf2:client:<salt>:<hash> | Two-pass — server did 100k on client-hashed (600k) input |
Version signaling
The client sends both the raw password and a clientHash (128-char hex, the output of client-side PBKDF2 at 600k) in the login request body. The server auto-detects the stored hash format:
- Stored
pbkdf2:client:→ verify usingclientHash. - Stored
pbkdf2:(legacy) → verify using rawpassword. If valid, re-hash topbkdf2:client:usingclientHash.
Rollout
- Deploy server-side support for both formats (new
hashPasswordvariant + dual-pathverifyPassword) - Deploy client-side pre-hashing — sends both raw
passwordandclientHashin login body - On next successful login of a legacy-hash user, re-hash to
pbkdf2:client:format - Seed, user creation, and onboarding guardian creation initially produce v1 hashes — these auto-upgrade on first login via re-hash-on-login
Consequences
Positive
- Effective ~700k-iteration security within the free tier's 10ms CPU budget
- Backward compatible — no forced password reset
- Gradual migration via re-hash-on-login
- No new dependencies or WASM bundles
Negative
- The client hash transmitted over the wire is the effective password from the server's perspective. If TLS is compromised, an attacker who intercepts the client hash can authenticate as that user. This is true of any password-authenticated protocol — TLS remains the defense.
- Two code paths in
verifyPasswordduring the migration window (legacy + two-pass) hashPasswordneeds a variant that accepts a pre-hashed input rather than a raw password
Neutral
- The existing
hashPasswordfunction signature accepts an optional version parameter (defaults to 1 for backward compat). - Offline auth (IndexedDB) is unaffected — it continues to store the 600k client hash directly.
- Server-side account creation (seed, onboarding approve, admin user creation) produces v1 hashes initially. These are upgraded to v2 on the user's first successful login from a v2-capable client.