If you store passwords, you don't store passwords. You store the output of a slow, salted hash function so that a database leak doesn't hand attackers everyone's plaintext. Bcrypt has been the boring, dependable choice for this since the late '90s, and it's still a perfectly good default in 2024.
What bcrypt actually is
Bcrypt is a password-hashing function built on the Blowfish cipher. Two properties make it good for passwords:
- It's deliberately slow. Unlike SHA-256, which is designed to be fast, bcrypt has an adjustable work factor (the "cost") that controls how many rounds of key setup it runs. More rounds means more CPU time per hash, which slows down brute-force attacks.
- It salts automatically. Every hash includes a random salt baked into the output string, so two identical passwords produce different hashes. You don't manage the salt yourself.
A bcrypt hash looks like this:
$2b$12$eImiTXuWVxfM37uY4JANjQ.M3vDe7z9Xw0hZ7Iu0iK9m1oPq2rSt6The pieces are: $2b$ (the algorithm version), 12 (the cost factor), then the next 22 characters are the salt, and the rest is the actual hash. Everything you need to verify a password later lives in that one string — you don't store the salt or cost in separate columns.
When to use it (and when not to)
Use bcrypt for user passwords, API secrets you need to verify, and anything else where you compare a user-supplied secret against a stored value. That's its job.
Don't use it for:
- General-purpose hashing. If you need a checksum or a fast content hash, use SHA-256. Bcrypt is slow on purpose — that's a feature for passwords and a bug for everything else.
- Encrypting data you need to read back. Hashing is one-way. If you need to recover the original, you want encryption, not hashing.
- Very long inputs. Bcrypt only looks at the first 72 bytes of input. More on that below.
Argon2 is the newer, arguably stronger option and won the Password Hashing Competition. If you're starting fresh and your language has a solid Argon2 library, it's worth considering. But bcrypt is well-understood, available everywhere, and not a mistake.
The gotchas
The 72-byte truncation
Bcrypt silently ignores everything past the first 72 bytes of the password. For most passwords this never matters, but if you let users paste passphrases or you pre-hash with something, you can end up with two different passwords that share the same first 72 bytes hashing identically. A common workaround is to pre-hash the password with SHA-256 and base64-encode the result before passing it to bcrypt — but only do this if you understand the trade-offs, and be consistent forever.
Null bytes
Some older bcrypt implementations stop reading at the first null byte. If a password contains one, everything after it is ignored. Modern libraries mostly handle this correctly, but it's a reason to be careful with pre-hashing schemes that produce raw binary.
Picking the cost factor
The cost is a power-of-two exponent, so cost 12 is twice the work of cost 11. Higher is more secure but slower. The right value depends on your hardware and how many logins per second you need to handle. A reasonable rule: tune it so a single hash takes roughly 200–300ms on your production server. Benchmark it — don't just copy a number off the internet. As hardware gets faster, bump the cost over time.
Comparing hashes
Always use the library's verify function, never a plain string equality check. The verify function extracts the salt and cost from the stored hash, re-hashes the input, and compares in constant time to avoid timing attacks. Rolling your own comparison defeats the point.
A worked example
Here's the typical flow in Node.js using the bcrypt package. Signup hashes and stores; login verifies.
const bcrypt = require('bcrypt');
const SALT_ROUNDS = 12;
// On signup
async function hashPassword(plain) {
return bcrypt.hash(plain, SALT_ROUNDS);
}
// On login
async function verifyPassword(plain, storedHash) {
return bcrypt.compare(plain, storedHash);
}
// Usage
const hash = await hashPassword('correct horse battery staple');
// store `hash` in the users table
const ok = await verifyPassword('correct horse battery staple', hash);
console.log(ok); // trueNotice you never handle the salt directly. bcrypt.hash generates one and embeds it; bcrypt.compare pulls it back out. The same pattern holds in Python (bcrypt or passlib), PHP (password_hash with PASSWORD_BCRYPT), Go (golang.org/x/crypto/bcrypt), and Ruby (bcrypt).
If you just want to eyeball what a bcrypt hash looks like for a given input and cost — say you're debugging why a migrated hash won't verify, or testing a seed value — you can generate one in the browser with the Bcrypt Hash Generator instead of spinning up a REPL.
Upgrading old hashes
One nice thing about bcrypt: because the cost is stored in the hash, you can migrate to a higher cost gradually. On successful login, check whether the stored hash's cost is below your current target. If it is, re-hash the plaintext (which you have in hand at that moment) and update the row. Over time your whole user base rolls forward without a mass re-hash you can't actually do — you don't have the plaintexts sitting around, after all.
async function loginAndMaybeRehash(plain, storedHash) {
const ok = await bcrypt.compare(plain, storedHash);
if (!ok) return null;
const currentCost = parseInt(storedHash.split('$')[2], 10);
if (currentCost < SALT_ROUNDS) {
const newHash = await bcrypt.hash(plain, SALT_ROUNDS);
// save newHash to the database
}
return true;
}The short version
Use bcrypt (or Argon2) for passwords. Let the library manage salts. Pick a cost that hurts a little on your hardware and raise it as time goes on. Watch the 72-byte limit if you do anything clever with input. And never compare hashes yourself. Get those right and password storage becomes one of the least interesting parts of your system — which is exactly what you want.