Skip to content

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 guess

Stored format

FormatMeaning
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 using clientHash.
  • Stored pbkdf2: (legacy) → verify using raw password. If valid, re-hash to pbkdf2:client: using clientHash.

Rollout

  1. Deploy server-side support for both formats (new hashPassword variant + dual-path verifyPassword)
  2. Deploy client-side pre-hashing — sends both raw password and clientHash in login body
  3. On next successful login of a legacy-hash user, re-hash to pbkdf2:client: format
  4. 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 verifyPassword during the migration window (legacy + two-pass)
  • hashPassword needs a variant that accepts a pre-hashed input rather than a raw password

Neutral

  • The existing hashPassword function 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.

Distribuído sob licença MIT.