bcrypt for your passwords: the post that starts with a pentest report
The email was four lines long, and it was fine. Your passwords are hashed with plain SHA-256, no salt, and the database backup from March is on a shared drive. That’s not a vulnerability, that’s a sentence.
The developer who filed it wasn’t angry. They were tired. What they’d have to do to fix it — install a library, swap the hasher, write a second code path for the old hashes, and get a migration through review — was more than they had budget for. So they asked, reasonably: why doesn’t Wheels just have this?
It was a good question, and two weeks later we had an answer in the framework: bcryptHash(), bcryptVerify(), and bcryptNeedsRehash() — global helpers with the same Blowfish “expensive key schedule” underneath. On JVM engines they run through a bundled jBCrypt (MIT) jar for speed, and fall back to a pure-CFML implementation — no CFX tags, no external libraries — when the jar isn’t on the classpath. RustCFML ships the helpers as native builtins, so on that engine Wheels steps aside and lets the engine serve them.
hashed = bcryptHash("correct horse battery staple"); // $2b$10$...
ok = bcryptVerify("correct horse battery staple", hashed); // true
needsUpgrade = bcryptNeedsRehash(hashed, 12); // true while cost is 10
But the helper is the easy part. The story is what happens when you actually use it, because that’s where the three-week migration lives.
The migration is the point
You can’t just swap the hasher. The database has ten thousand SHA-256 rows and you can’t read them — hashes aren’t reversible. So you do the two-step dance:
- New passwords go through
bcryptHash(). Now. Not “after the migration” — now, so the attack window closes the moment you deploy. - Old hashes keep verifying through your old routine until the user next logs in. On that login, you re-hash with bcrypt and overwrite the stored value.
bcryptNeedsRehash() is the hook for step two. It tells you whether a stored hash’s cost factor no longer matches your policy — so a user with a cost-10 hash walks into your cost-12 world, logs in, and gets upgraded on the spot, no background job required:
if (bcryptNeedsRehash(user.passwordHash, 12)) {
user.passwordHash = bcryptHash(inputPassword, 12);
user.save();
}
That’s the whole “migrate” strategy. The framework can’t do it for you, and it shouldn’t — you’re changing a secret’s format, which is exactly the kind of thing that deserves an eyeball. But the four-line helper turns three weeks into an afternoon.
What the cost factor actually costs you
bcrypt’s cost is exponential: every +1 doubles the work, and that has teeth — which is why the helpers don’t run the same way on every engine.
- On a JVM (Lucee, Adobe CF, BoxLang), a cost-10 hash runs through the bundled jBCrypt jar and costs roughly 100 ms — the number you’d expect from any native bcrypt library.
- When the jar isn’t available, the pure-CFML fallback takes over, and it’s honest about the cost: roughly 14 seconds for cost 10 on Lucee 7. That’s the price of doing Blowfish arithmetic in interpreted CFML, and it’s exactly why we ship the jar rather than making you install one.
For speed reasons Wheels bundles jBCrypt and loads it automatically from vendor/wheels/resources/java; when it can’t be resolved (no JVM, or the class isn’t on the classpath) the helpers fall back to the CFML path, and on RustCFML they defer to the engine’s native builtins. The API never changes — only the constant does.
Practical sizing:
- Cost 10 is the default and the right call for production on a JVM.
- If you’re exercising the CFML fallback (a non-JVM engine), measure it — cost 4–6 for test suites and local dev.
- The helpers validate cost up front and throw
Wheels.InvalidArgumentoutside 4–31, sobcryptHash(password, 1)fails loud instead of quietly hashing with a cost any GPU would shrug off.
The part where we questioned our sanity
Cross-engine CFML is where “just implement bcrypt” turns into a detective story. Two engines misbehaved in ways that no amount of reading the docs would have predicted:
- Adobe CF 2023 bit functions reject operands above 2³¹−1, and its
modandBitSHRNcoerce operands differently than Lucee. So the word arithmetic had to be rewritten to stay inside signed 32-bit range and extract bytes arithmetically. - Then the nasty one: Adobe passes arrays by value. The EksBlowfish key schedule mutates the P/S arrays in place. On Adobe, those mutations were silently discarded — so hashes computed, but they were wrong. Same vector, wrong checksum, no error. The fix was making the key schedule return its state instead of trusting by-reference mutation, and the classic symptom — “it works on my machine” — became a test vector pinned in the suite so it can never regress.
There’s also a happier engine note: RustCFML ships bcryptHash()/bcryptVerify() as native builtins, so Wheels detects that at boot and lets the engine serve them — and provides bcryptNeedsRehash() itself, since no engine has that one.
The suite now verifies the helpers against external vectors: an OpenBSD $2b$ checksum from Apache’s htpasswd, and the official jBCrypt $2a$08$ vector. “It matches on every engine we support” is a sentence we can now say and mean.
The moral
The framework shipped bcrypt because a tired developer asked a good question and we couldn’t point at a good answer. If you’ve got a hash() plus SHA in your model, this is your clean exit. New hashes today, upgrade-on-login for the old ones, two helpers you’ll rarely think about again.
And if it ever takes fourteen seconds, you’ve fallen off the JVM fast path onto the CFML fallback — the helpers keep working either way, but check that the bundled jar is on the classpath. On a JVM it should cost a tenth of a second, not a coffee break.
Next up in the series: read the next post — session auth in one line, and the three bugs you won’t have to find the hard way. (See the series index.)
Comments