PBKDF2 Password Hashing
Status¶
Accepted
Supersedes
adr-0001-web-crypto-pbkdf2.md— that record was "Proposed" and referenced the wrong implementation file (functions/utils/auth.js). The canonical implementation isfunctions/utils/crypto.js.
Context¶
SetTimes runs all backend logic inside Cloudflare Workers V8 isolates. That runtime has two constraints directly relevant to password hashing:
No native binary addons. Workers isolates do not support Node.js native modules (.node files). bcrypt, Argon2, and scrypt all depend on native binaries to achieve production-safe work factors — pure-JavaScript implementations of these algorithms are too slow to be secure at practical iteration counts without exceeding Workers resource limits.
CPU time metering. Workers meter JavaScript execution time and enforce per-request CPU limits. Free-plan Workers are constrained to approximately 10 ms of metered CPU time; paid plans have higher but still bounded limits. A pure-JavaScript password hash at a secure work factor (e.g., bcrypt cost 12, Argon2 with 64 MiB memory) consumes enough CPU to trigger Cloudflare error 1102 ("Worker exceeded resource limits") on sensitive plan configurations.
The Web Crypto API (crypto.subtle) is available natively in the Workers runtime. crypto.subtle.deriveBits() with PBKDF2 executes in native V8 code outside the metered JavaScript CPU budget, making it the only practical path to a work-factored password hash in this environment.
The implementation lives in functions/utils/crypto.js and exports hashPassword and verifyPassword. A legacy hash format (salt:hash without algorithm prefix) exists in the database from pre-pbkdf2$ code; verifyPassword handles it transparently for backward compatibility.
Decision¶
Use PBKDF2-SHA256 via crypto.subtle.deriveBits() with 100,000 iterations for all new password hashes.
Hash storage format:
pbkdf2$<iterations>$<base64url-salt>$<base64url-hash>
Implementation constants in functions/utils/crypto.js:
| Constant | Value | Purpose |
|---|---|---|
SALT_LENGTH |
16 bytes | Per-password random salt from crypto.getRandomValues() |
DEFAULT_ITERATIONS |
100,000 | Work factor for new hashes |
KEY_LENGTH |
32 bytes | Derived key length (256 bits) |
Comparison is performed with a constant-time timingSafeEqual function to prevent timing side-channels. The iteration count is embedded in the stored hash string so future iteration increases can be handled transparently via rehash-on-login without requiring a forced password reset.
No bcrypt or Argon2 dependency may be introduced in functions/. The better-sqlite3 native module used in test helpers (Node.js environment only) must never be mistaken for a pattern to replicate in production Worker code.
Consequences¶
Positive¶
- POS-001: Password hashing completes reliably within Workers CPU limits on all plan tiers because
crypto.subtleexecutes outside the metered JavaScript budget. - POS-002: Zero additional runtime dependencies — the implementation relies exclusively on the Web Crypto API that is already available in the Workers global scope.
- POS-003: The iteration count is stored in the hash string, enabling transparent work-factor upgrades via rehash-on-login without a global password reset.
- POS-004: Timing-safe comparison (
timingSafeEqual) is implemented inline, eliminating the risk of a timing side-channel in the verification path. - POS-005: Legacy
salt:hashformat from pre-migration hashes is handled gracefully inverifyPassword, avoiding a forced migration event for existing users.
Negative¶
- NEG-001: PBKDF2-SHA256 is not memory-hard. Under equivalent attacker resources, it provides weaker resistance to GPU or ASIC cracking than Argon2id, which is the OWASP-preferred algorithm for a non-constrained runtime.
- NEG-002: The 100,000 iteration count is a platform-constrained compromise, not a security-optimized choice. OWASP recommends Argon2id with 19 MiB memory and 2 iterations as the primary recommendation.
- NEG-003: If the deployment platform ever moves to a standard Node.js runtime, this decision should be revisited — the Workers constraint that necessitated it no longer applies.
- NEG-004:
LEGACY_ITERATIONSis currently defined at the same value asDEFAULT_ITERATIONS(100,000), so legacy hashes are indistinguishable by iteration count from new hashes. The legacy format is identified only by the absence of thepbkdf2$prefix.
Alternatives Considered¶
bcrypt¶
- ALT-001: Description: Use bcrypt for password hashing (e.g., via the
bcryptjspure-JavaScript library or a native Node.js module). - ALT-002: Rejection Reason: Native bcrypt binaries are not supported in Workers isolates. Pure-JavaScript bcrypt at a secure cost factor (≥12) executes inside the metered CPU budget and triggers error 1102 on constrained plan tiers. This makes the authentication path unreliable in production.
Argon2id¶
- ALT-003: Description: Use Argon2id, the OWASP-recommended algorithm for password hashing.
- ALT-004: Rejection Reason: Argon2 requires a native binary and is also memory-hard by design. Workers enforce per-request memory limits. Both the binary constraint and the memory-hardness property make Argon2 impractical in the current runtime. If the platform changes, Argon2id should be evaluated as the primary migration target.
scrypt¶
- ALT-005: Description: Use scrypt via Node.js
crypto.scryptor a compatible library. - ALT-006: Rejection Reason: Same native binary constraint as bcrypt and Argon2. The Workers runtime does not expose
crypto.scrypt— only the Web Crypto API subset.
SHA-256 without key stretching¶
- ALT-007: Description: Hash passwords with a single pass of SHA-256 (with or without a static salt).
- ALT-008: Rejection Reason: SHA-256 is a fast hash with no work factor. It is unacceptable for password storage under any threat model — a leaked database would be crackable with commodity hardware in seconds.
Implementation Notes¶
- IMP-001: All password hashing and verification must go through
functions/utils/crypto.js. Do not introduce a second implementation or inlinecrypto.subtle.deriveBits()calls elsewhere. - IMP-002: Never introduce
bcrypt,argon2,scrypt, or any native binary package as a dependency infunctions/. These cannot load in the Workers runtime and will cause silent startup failures. - IMP-003:
better-sqlite3is used in test helpers underNode.js— this is acceptable in the test environment only. It must never appear infunctions/production code. - IMP-004: If the
DEFAULT_ITERATIONSconstant is increased in future,LEGACY_ITERATIONSmust also be updated or versioned accordingly to preserve backward-compatible verification of older hashes. - IMP-005: On a successful password verification for a legacy-format hash, consider triggering a rehash with
hashPassword()and persisting the newpbkdf2$formatted hash to complete the migration transparently. - IMP-006: On migration away from the Workers runtime, evaluate replacing this implementation with Argon2id (
argon2npm package) using OWASP-recommended parameters:type: argon2id,memoryCost: 65536(64 MiB),timeCost: 3.
References¶
- REF-001:
functions/utils/crypto.js— canonical implementation (hashPassword,verifyPassword,timingSafeEqual). - REF-002:
CLAUDE.md— "PBKDF2, not bcrypt" section documents this invariant for AI assistants working in this codebase. - REF-003: OWASP Password Storage Cheat Sheet — recommends Argon2id as primary; PBKDF2-SHA256 at ≥600,000 iterations as FIPS-compliant fallback (our 100k reflects platform constraints, not a departure from intent).
- REF-004: Cloudflare Workers Runtime APIs — Web Crypto — documents
crypto.subtleavailability and its exclusion from the Workers CPU time meter. - REF-005: ADR-0003: Cloudflare D1 as Primary Database — background on the Cloudflare Workers hosting decision that creates this constraint.