How bcrypt works
bcrypt was published in 1999 by Niels Provos and David Mazières and is built on the Blowfish block cipher. Its key idea is a tunable work factor: the expensive Blowfish key-setup step is repeated 2cost times, so raising the cost by one doubles the time needed to compute a hash. Hardware gets faster every year; with bcrypt you answer that by incrementing a number rather than migrating to a new algorithm.
bcrypt also generates its own 128-bit salt and packs everything you need for verification into one self-describing string. That is why a bcrypt column needs no companion salt column and why you should always store the full 60-character output verbatim.
Anatomy of a bcrypt hash
$2a$ — algorithm version identifier
12$ — cost factor: 212 = 4,096 key-setup rounds
R9h/cIPz0gi.URNNX3kh2O — 22 characters of base64-encoded salt (16 bytes)
PST9/…MUW — 31 characters of base64-encoded digest (23 bytes)
Choosing a cost factor
| Cost | Rounds | Order of magnitude | Where it fits |
|---|---|---|---|
| 4 | 16 | ~1 ms | Unit tests only |
| 10 | 1,024 | ~60 ms | Common framework default |
| 12 | 4,096 | ~250 ms | Recommended for web apps |
| 14 | 16,384 | ~1 s | High-value accounts, admin logins |
Timings are illustrative — measure on your own hardware, because bcrypt cost is a latency budget you spend on every single login. If you serve thousands of logins per second, raising cost also raises CPU demand linearly. A practical rule: pick the highest cost that keeps login under roughly 250 ms at peak, and re-hash a user's password to the new cost transparently the next time they log in successfully.
Server-side examples
Node.js with bcrypt:
const bcrypt = require('bcrypt');
const hash = await bcrypt.hash(plainPassword, 12);
const ok = await bcrypt.compare(candidate, hash);
PHP (password_hash uses bcrypt by default):
$hash = password_hash($plain, PASSWORD_BCRYPT, ['cost' => 12]);
$ok = password_verify($candidate, $hash);
if (password_needs_rehash($hash, PASSWORD_BCRYPT, ['cost' => 12])) { /* re-hash */ }
Python with passlib:
from passlib.hash import bcrypt hash = bcrypt.using(rounds=12).hash(plain) ok = bcrypt.verify(candidate, hash)
Four bcrypt mistakes worth avoiding
- Truncating the column. A
VARCHAR(50)silently cuts a 60-character hash and every login fails. Use 60 or more, or 255 to allow future algorithms. - Ignoring the 72-byte limit. bcrypt only reads the first 72 bytes of input. If you accept passphrases, pre-hash with SHA-256 and base64-encode the digest before calling bcrypt so long inputs stay distinct.
- Pre-hashing with raw binary. Do not feed raw SHA output containing null bytes into bcrypt — some implementations stop at the first null byte, collapsing your key space.
- Comparing strings.
hash(candidate) === storedHashnever matches because the salt differs. Always use the library's compare function.
bcrypt vs Argon2 vs scrypt vs PBKDF2
Argon2id is the current first choice when your platform has a well-maintained binding: it is memory-hard, so GPU and ASIC attackers gain much less than they do against bcrypt. scrypt is also memory-hard and widely available. PBKDF2-SHA256 is the option in FIPS-constrained environments, but it is only CPU-hard, so it needs very high iteration counts. bcrypt sits in the sweet spot of "battle-tested, available everywhere, hard to misconfigure", which is why it remains a perfectly defensible choice in 2026 — and why frameworks from Laravel to Django still ship it. If you need to migrate later, verify with the old algorithm on login and re-hash with the new one.