The sign-in route was the one part of the app I had never opened.
It came with the framework. Better Auth handles email and password sign-in, you hand it a database adapter, and passwords become somebody else's problem. That is most of the reason you install it.
Then we moved the app onto Cloudflare Workers, and I finally went and read what the library does when somebody types their password.
It runs scrypt, in pure JavaScript, with parameters that ask for 32 megabytes of memory on every call.
A Worker isolate gets 128 MB and is billed for CPU time. Four logins at once is the entire budget.
So we replaced it. Swapping the hasher took an afternoon. Everything worth writing down happened afterwards: the format we now have to keep verifying forever, and the number we picked at the end, which I think is wrong.
What a password hasher actually is
You never store a password. You store the result of running it through a key derivation function, and when somebody signs in you run their attempt through the same function and compare the two results.
Think of grinding coffee. Beans go in, powder comes out, and nothing you do to the powder gives you the beans back. A password hasher is that with one addition: the grinder is geared down on purpose, so one cup is fine and ten million cups is a month of somebody's life.
Two more pieces and you have the whole idea.
A salt is sixteen random bytes, generated per password and stored next to the result. Without one, two people who both picked october2024 end up with the same stored string, and cracking one cracks both.
A work factor is the gearing. It is a number you turn up as hardware gets faster, and for most of these functions it is the only defence there is. Everything below is an argument about that number.
Why 32 MB is the wrong shape for a Worker
Better Auth's default comes from @better-auth/utils, which wraps @noble/hashes:
import { scryptAsync } from '@noble/hashes/scrypt.js'
// N is the cost, r the block size, p the parallelism
const config = { N: 16384, r: 16, p: 1, dkLen: 64 }scrypt is memory-hard. Its working set is 128 * N * r bytes, and 128 times 16,384 times 16 comes to 33,554,432 bytes. That is 32 MB, per hash, per login.
This is not a flaw. It is the entire design. Memory is expensive to parallelise, so a rack of GPUs gets far less leverage against scrypt than against a function that only costs time. scrypt is the genuinely stronger of the two functions in this post, and I want that on the record before I explain why we stopped using it.
The problem is the runtime, not the algorithm. Workers caps an isolate at 128 MB and meters CPU time, and one isolate serves many concurrent requests. A function whose whole selling point is being expensive in memory and CPU is asking for the two things that platform counts.
The second problem is that @noble/hashes is JavaScript. It is a careful library and I have no complaint about it, but it cannot reach the native crypto the runtime already ships. Every one of those memory operations runs inside the JS engine.
What we put in its place
PBKDF2, through WebCrypto.
const ITERATIONS = 10_000
export async function hashPassword(password: string) {
const salt = crypto.getRandomValues(new Uint8Array(16))
const key = await deriveBits(password, salt, ITERATIONS)
return `pbkdf2:sha256:${ITERATIONS}:${b64(salt)}:${b64(key)}`
}deriveBits is crypto.subtle.deriveBits, which the runtime implements rather than JavaScript. On Workers that means BoringSSL running the loop instead of V8.
The trade is real and worth stating plainly. PBKDF2 is not memory-hard, so per millisecond of your time it buys less attacker pain than scrypt does. We took that deal because the platform hands us a native implementation of one and a JavaScript implementation of the other, and native wins by enough to change the answer.
The stored string carries its own parameters:
pbkdf2:sha256:10000:<base64 salt>:<base64 key>That prefix is the most useful thing in the file, and it is worth stealing even if you never touch PBKDF2.
Two formats in one column
We could not rehash the passwords we already had, because we do not have them. Only the user holds the plaintext, and only for the instant they type it. So both formats live in user.password indefinitely, told apart by whether the string has a prefix at all.
| Format | Shape | Written by |
|---|---|---|
| Legacy | hexsalt:hexkey |
Better Auth scrypt |
| Current | pbkdf2:sha256:... |
us |
export async function verifyPassword({ hash, password }) {
if (!hash.startsWith('pbkdf2:')) {
const { verifyPassword: scrypt } = await import('@better-auth/utils/password')
return scrypt({ hash, password })
}
const [, , iterStr, saltB64, keyB64] = hash.split(':')
const derived = await deriveBits(password, b64d(saltB64), parseInt(iterStr, 10))
return timingSafeEqual(derived, b64d(keyB64))
}Two things in there are load bearing.
The scrypt fallback is a dynamic import, so @noble/hashes is not dragged into the path every login takes. Only the logins that actually need it pay to load it.
The iteration count is read out of the hash, not out of the constant. That is what makes the number changeable at all. A hash written at 600,000 still verifies at 600,000 after the constant drops to 10,000, and the two sit in the same column with no migration and nobody reset.
Here is the whole path a sign-in takes:
flowchart TD
A["signInEmail(email, password)"] --> B{"user exists?"}
B -->|"no"| G["Incorrect email or password"]
B -->|"yes"| C{"hash starts with pbkdf2: ?"}
C -->|"yes"| D["read iterations and salt from the hash,<br/>derive with those, not the constant"]
C -->|"no"| E["dynamic import scrypt,<br/>verify the legacy format"]
D --> F{"constant-time compare"}
E --> F
F -->|"match"| H["issue session cookie"]
F -->|"no match"| GA wrong password walks the same path at the same cost, because the derivation always runs in full and a different input produces an entirely different key. The message is generic either way, and the timing is dominated by the hash, so neither the wording nor the duration tells an attacker whether that email exists.
There is one thing we chose not to do. On a successful legacy verify we are holding the plaintext for that instant, so we could rehash it to PBKDF2 and write it back, migrating each user silently on their next sign-in. We left the verify path free of side effects instead, and old users pay the scrypt cost on every login, forever. It is the obvious next change if that ever shows up as a real cost.
The number I would put back
ITERATIONS started at 600,000, which is what OWASP recommends for PBKDF2-SHA256. It was cut to 10,000.
Measured on a laptop, Apple silicon, the same V8 that Workers runs:
| KDF | Cost |
|---|---|
| PBKDF2-SHA256, 10,000 iterations | 1.0 ms |
| PBKDF2-SHA256, 100,000 iterations | 6.3 ms |
| PBKDF2-SHA256, 600,000 iterations | 36.3 ms |
| scrypt at the Better Auth defaults, pure JS | 37.1 ms, plus 32 MB |
Read the last two rows together, because between them they undo the reason for the cut. PBKDF2 at full OWASP strength costs about the same as scrypt on this hardware and uses 32 MB less memory. The move off scrypt is justified on memory and native implementation alone. It never needed the iteration count touched at all.
The cut from 600,000 to 10,000 bought about 35 milliseconds per login and gave away a factor of 60 in what it costs to crack a stolen database offline. If Workers CPU was the worry, 100,000 iterations at roughly 6 ms would have answered it and stayed within an order of magnitude of the recommendation.
And nobody has measured any of this on Workers. The comment in the file cites 800 to 1500 ms for scrypt on edge runtimes, which is far worse than the laptop shows and is plausible for a 32 MB allocation inside a constrained isolate. It is still a number we inherited rather than one we took. If this gets revisited, that measurement is the first step and not the last.
10,000 is a deliberate team decision, not an oversight. Writing it down is the point. A compromise you can see is one somebody can argue with later.
The details that are easy to lose
Compare in constant time. Better Auth compares hex strings with ===, which stops at the first byte that differs and leaks where that was through timing. XOR every byte into an accumulator instead, and check once at the end:
function timingSafeEqual(a: Uint8Array, b: Uint8Array) {
if (a.length !== b.length) return false
let diff = 0
for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i]
return diff === 0
}Normalise the input. Call password.normalize('NFKC') before encoding. The same password typed on two keyboards can be two different byte sequences, because é is either a single codepoint or an e plus a combining accent. Better Auth's scrypt normalises too, and quietly dropping it during the swap would have locked people out of their own accounts in a way nobody could reproduce.
Fail closed on garbage. A malformed hash returns false rather than throwing. A corrupted row should cost one login, not the whole auth route.
When to replace a framework's hasher
Reasonable:
- The default cannot run on your runtime, or runs badly enough there to break it.
- You are migrating into the framework and need a format it does not produce.
- You can keep verifying the old format, so no existing user is forced to reset.
Not reasonable:
- You believe you can pick better parameters than the library authors did. This post is the counterexample. The one parameter we chose ourselves is the one now carrying a documented compromise.
- You want it faster and have not measured. Password hashing is supposed to be slow. 36 ms is not a performance problem, it is the thing you are buying.
If you take one thing from this, take the version-tagged format. pbkdf2:sha256:600000:... carries its own algorithm, digest and cost, so all three can change later with no migration and no forced reset. Better Auth's bare salt:key cannot, and it only works because those parameters have never moved. When you go to Argon2id, and you probably should, that prefix is what lets both formats share a column while users drift across.
The short version
- Better Auth hashes with scrypt in pure JavaScript, at 32 MB per call. A Workers isolate is capped at 128 MB and billed for CPU.
- We moved to PBKDF2 through WebCrypto, which the runtime implements natively.
- scrypt is the stronger function. We gave that up for one the platform can actually run.
- Legacy hashes are spotted by the missing prefix and verified through a dynamically imported fallback. Nobody was reset.
- Store the parameters inside the hash. That is what makes the cost changeable afterwards.
- Cutting iterations from 600,000 to 10,000 saved 35 ms and cost a factor of 60 offline. At full strength PBKDF2 already matched scrypt on speed, so the cut bought nothing the migration had not already won.
- Measure on the runtime you deploy to. We have not.
The auth system in this post was built by Rishav Surana and Rohan Gupta. They wrote the code. I learned how it works from them, and wrote it up here.
Comments
No account needed. Pick a name and say what you think.