Node doesn't enforce it. wrangler dev doesn't enforce it. So a benchmark and a full end-to-end migration test both passed locally, and production broke in a way that was invisible to every existing user.
I build WhosWhoZoo, a self-hosted personal memory assistant that runs entirely inside the user's own Cloudflare account. Passphrase verification happens in a Worker. This is a writeup of a bug I shipped, why every test I had said it was fine, and the genuinely counterintuitive thing I found at the end of it.
The app stores a passphrase hash in KV. Originally that record was just { hash, salt }, with the iteration count living in a constant in the code. That's a design with a known trap: the day you raise the constant, every existing hash becomes unverifiable, because you're now checking old hashes at a cost they were never computed with. Everyone gets locked out at once.
So I made the cost part of the record:
auth_config = { hash, salt, iterations }
and established the invariant that matters: a hash is always verified at the cost that produced it, never at the current constant. Verify at record.iterations, and after a successful login, transparently re-hash at the current cost. Old records keep working. New records get the better cost. Nobody is locked out.
That part was correct, and it's the only reason this story has a happy ending.
While I was in there, I did the obvious adjacent thing: bumped the current cost from 100,000 to OWASP's recommended 600,000.
Not nothing. Specifically:
wrangler dev.All of it passed. I shipped it.
In production, /initPassword and /changePassword broke. Every new install. Every passphrase change.
Logins kept working perfectly.
That combination is why it took a while to notice. Verification reads the cost off the record, and every record already in existence said 100000 — so every existing user, including me, continued logging in with no symptom at all. The only people who could hit the bug were people creating a new hash. New customers, and anyone changing their passphrase. The two groups least likely to be me.
iteration counts above 100000 are not supported — thrown by workerd at derive time. Cloudflare Workers caps PBKDF2 at 100,000 iterations as a CPU-abuse guard. It is a hard ceiling, not a soft warning, and it fires when you try to derive, not when you configure.
Here's the part worth internalizing, because it generalizes well past PBKDF2.
Neither Node nor wrangler dev enforces that ceiling. My benchmark ran in Node. My end-to-end migration test ran locally. Both of them derived keys at 600,000 iterations quite happily, because the local environments have no such limit — and so both of them confirmed, with total confidence, a behavior that does not exist in production.
I had treated wrangler dev as "workerd, locally." It isn't. It's workerd with the governor removed.
When I went to fix it, I found the same app derives keys in a second place — and that one was also at 600,000, and working fine.
The app's encrypted export runs its KDF in the browser, not in the Worker. And browsers impose no PBKDF2 iteration limit at all. Web Crypto in Chrome or Safari will run 600,000 iterations without complaint. The 100,000 cap is a workerd restriction, not a Web Crypto one.
So the correct end state for this codebase is two different iteration counts for the same algorithm in the same product:
Which means the tidy-looking cleanup — "these two constants disagree, let's make them consistent" — would take every encrypted backup the product has ever produced and make it six times cheaper to attack, in exchange for nothing but visual symmetry. The inconsistency is the correct state. It now has a comment on it explaining that at some length, because I do not trust my future self with this.
The constant went back to 100,000, and a test now pins it:
assert(PBKDF2.current <= 100000)
That's a cheap stand-in for a class of failure my harness structurally cannot catch. It won't detect the next platform limit I run into, but it will stop me re-raising this specific one after I've forgotten why it's low.
The deeper takeaway isn't about iteration counts. It's that the versioned-per-record design — which I built to make a future cost upgrade safe — is what contained a present mistake. Because the cost lived on each record instead of in a global constant, shipping a bad value broke hash creation but left verification untouched. Every existing user kept working through a bug that, under the previous design, would have locked out all of them simultaneously.
I'd like to claim I planned that. I didn't. I built the migration safety net for a different reason, and it happened to be standing in the right place when I fell.