How Bcrypt Password Hashing Works: Eksblowfish Key Schedule, Cost Factor Mathematics, the 72-Byte Truncation Trap, and Argon2id Migration (2026)
In modern web application security, storing user passwords securely in databases is a fundamental requirement. Yet security teams still routinely uncover production architectures that store credentials using fast cryptographic digests like MD5, SHA-1, SHA-256, or naive salted hashes.
Fast hash functions are engineered for high-throughput checksum validation and data integrity. Modern desktop GPUs (such as an NVIDIA RTX 4090) compute over 25 billion SHA-256 hashes per second. If a database table containing salted SHA-256 hashes is leaked, an attacker can crack typical user passwords within minutes using tools like Hashcat.
Standardized by Niels Provos and David Maziรจres in 1999 for OpenBSD, Bcrypt was intentionally designed to be computationally expensive, memory-bound, and adaptively slow.
This comprehensive engineering guide dissects how Bcrypt works under the hood: the Eksblowfish (Expensive Key Schedule Blowfish) architecture, the anatomy of the 60-character modular crypt string, the exponential mathematics of cost factors, the dangerous 72-byte truncation trap, and modern migration paths toward Argon2id (aligned with OWASP 2026 password storage standards).
1. Why Fast Hashes (SHA-256 / MD5) Fail for Passwords
Cryptographic hash functions fall into two distinct engineering paradigms:
+-------------------------------------------------------------------------------------------------+
| FAST HASHES vs. PASSWORD HASHES |
+-------------------------------------------------------------------------------------------------+
| Property | Fast Hashes (SHA-256, BLAKE3, MD5) | Password Hashes (Bcrypt, Argon2id) |
+---------------------+-----------------------------------------+---------------------------------+
| Design Objective | Maximize throughput (GB/s on CPU/ASIC) | Minimize cracker speed (Compute/Memory bound)|
| Hardware Scaling | Billions of guesses/sec on consumer GPU | Hundreds of guesses/sec on GPU |
| Adaptive Work Factor| No (Fixed computational steps) | Yes (Exponentially tunable) |
| Built-in Salt | Requires manual application & storage | Natively embedded in output |
| Target Use Case | File checksums, HMAC, Git commits | Password verification, Key derivation |
+-------------------------------------------------------------------------------------------------+
When an attacker dumps a password table, they run offline brute-force attacks. Because they control the hardware, rate limits on your login endpoint cannot protect the database dump. Protection relies entirely on the per-hash computational cost imposed by the algorithm.
2. The Internal Mechanics of Bcrypt: Eksblowfish
Bcrypt is built on top of Bruce Schneier's Blowfish symmetric block cipher, replacing its key schedule with Eksblowfish (Expensive Key Schedule Blowfish).
Blowfish uses a Feistel network with sixteen 32-bit subkeys ($P$-array: $P_1 \dots P_{18}$) and four 256-entry S-boxes ($S_1 \dots S_4$, each containing 8-bit to 32-bit substitutions), initialized with the fractional digits of $\pi$.
Passphrase + 128-bit Salt
โ
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโ
โ SetupState (ฯ-hex) โ
โโโโโโโโโโโโโฌโโโโโโโโโโโโ
โ
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ ExpandKey(state, salt, password) โ
โโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโ
โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโดโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ โ
โผ โผ
โโโโโโโโโโโโโ โโโโโโโโโโโโโ
โ Cost Iter โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค Round: 2^cโ
โโโโโโโฌโโโโโโ โโโโโโโโโโโโโ
โ
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Encrypt "OrpheanBeholderScryDoubt" 64 times (192 bits / 24B)โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ
โผ
Final 60-character Modular Crypt String
The Three-Phase Pipeline
- State Initialization (
SetupState): The $P$-array and $S$-boxes are loaded with the standard mathematical constant $\pi$ hex values. - Key Expansion with Salt & Password (
ExpandKey): The 128-bit random salt and password are woven into the state. Then, the algorithm loops $2^{ ext{cost}}$ times, cyclically re-encrypting the state with the salt and the password. This guarantees that neither salt alone nor password alone can pre-compute subkeys. - Ciphertext Encryption (
OrpheanBeholderScryDoubt): Once the subkeys are fully expanded, the cipher encrypts the fixed 24-byte ASCII string"OrpheanBeholderScryDoubt"64 consecutive times using electronic codebook (ECB) mode. The resulting 192-bit ciphertext forms the final checksum.
Because Eksblowfish repeatedly alters all 4KB of S-box lookup tables in tight memory cycles, executing Bcrypt on GPUs is hindered by memory-divergent branch stalls, nullifying traditional GPU parallelism advantages over CPUs.
3. Dissecting the 60-Character Bcrypt Hash Format
A standard Bcrypt hash is serialized into the Modular Crypt Format (MCF), exactly 60 ASCII characters long:
$2b$12$e8YvXmK.u2QeZ4J8oYpU7eM0xWb9N.Z1iV8jL2oQ4uG6wA8bC0dE
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ โ โ โ
โ โ โ โโ Checksum (31 chars = 24 bytes)
โ โ โโ 128-bit Salt (22 chars = 16 bytes)
โ โโ Cost Factor (2 digits = 2^12 = 4,096 iterations)
โโ Algorithm Prefix ($2a$, $2b$, or $2y$)
1. Algorithm Identifiers ($2a$, $2b$, $2y$)
$2$(1999): Original OpenBSD implementation.$2a$(2002): Added specification for handling UTF-8 zero-termination.$2y$(2011): Introduced in PHP's crypt library to fix an unsigned vs. signed 8-bit character bug where characters with byte values > 127 caused hash mismatches.$2b$(2014): The modern, universal standard. Fixed an OpenBSD wraparound integer overflow bug. Always use$2b$in modern codebases.
2. Radix-64 Encoding
Bcrypt uses a custom base-64 alphabet for serialization:
./ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789
(Note: Standard RFC 4648 Base64 begins with A-Z, while Bcrypt Radix-64 begins with . and /).
4. The Critical Engineering Trap: 72-Byte Password Truncation
The most notorious architectural caveat of Bcrypt is its hard 72-byte password length ceiling.
In the Blowfish algorithm, the key schedule processes the key in 32-bit words up to 448 bits ($56 imes 8$ bits). Bcrypt extends this to a maximum internal buffer of 72 bytes (576 bits).
What Happens When Passwords Exceed 72 Bytes?
Any bytes beyond the 72nd byte are completely ignored.
// These two passwords generate the EXACT same Bcrypt hash:
const pass1 = "A".repeat(72) + "SuperSecretPassword123!";
const pass2 = "A".repeat(72) + "TotallyDifferentPassword999?";
// bcrypt.compareSync(pass1, hashOfPass2) === true!
The Multi-byte UTF-8 Hazard
This is a byte limit, not a character limit.
- ASCII characters take 1 byte:
"password123"= 11 bytes. - Accented European characters take 2 bytes:
"cafรฉ"= 5 bytes. - Chinese, Japanese, or Korean characters take 3 bytes:
"ๆ็ๅฏ็ ๅพ้ฟๅฎๅ จๅ"(9 chars) = 27 bytes. - Emojis take 4 bytes:
๐๐๐ก๏ธ๐(4 chars) = 16 bytes.
A user providing an 18-character Japanese passphrase or a password with several emojis can inadvertently trigger silent truncation at 72 bytes.
How to Fix Truncation Properly
If your application allows passphrases of arbitrary length (e.g. up to 1,024 characters), pre-hash the password using SHA-256 or SHA-512 before feeding it into Bcrypt:
import crypto from 'node:crypto';
import bcrypt from 'bcrypt';
function safeHashPassword(plaintext: string, rounds = 12): Promise<string> {
// Pre-hash with SHA-256 to collapse arbitrary-length input into exactly 32 bytes (64 hex characters)
const preHashed = crypto.createHash('sha256').update(plaintext, 'utf8').digest('hex');
return bcrypt.hash(preHashed, rounds);
}
async function safeVerifyPassword(plaintext: string, hash: string): Promise<boolean> {
const preHashed = crypto.createHash('sha256').update(plaintext, 'utf8').digest('hex');
return bcrypt.compare(preHashed, hash);
}
5. Cost Factor Mathematics and Sizing Guidelines (2026)
The cost parameter $c$ in $2b$c$ represents an exponential work scale:
$$ ext{Iterations} = 2^c$$
Every time you increment the cost factor by 1, you double the CPU time required to compute or verify a password.
+------------------------------------------------------------------------------------+
| BCRYPT COST FACTOR BENCHMARK TABLE |
+------------------------------------------------------------------------------------+
| Cost Factor | Iterations | Typical Server Latency (vCPU) | Acceptability (2026) |
+-------------+------------+-------------------------------+-------------------------+
| 8 | 256 | ~4 ms | Insecure (Legacy only) |
| 10 | 1,024 | ~18 ms | Bare minimum |
| 12 | 4,096 | ~75 ms | Recommended for APIs |
| 13 | 8,192 | ~150 ms | High-security systems |
| 14 | 16,384 | ~310 ms | Batch / Admin accounts |
+------------------------------------------------------------------------------------+
The 250ms Rule for Authentication Endpoints
- Target Latency: Aim for 100ms to 250ms per password verification on your production infrastructure.
- Denial of Service (DoS) Caution: Never set cost factors higher than 14 on synchronous public endpoints without strict IP rate-limiting; otherwise, malicious actors can send bursts of concurrent invalid logins to starve server CPUs.
6. Bcrypt vs. Modern Password Hashers: When to Choose Argon2id
In 2015, the Password Hashing Competition (PHC) selected Argon2 as the next-generation global standard. In 2026, OWASP recommends Argon2id as the primary choice for greenfield systems.
| Feature | Bcrypt (1999) | PBKDF2-HMAC-SHA256 (2000) | Argon2id (2015/RFC 9106) |
|---|---|---|---|
| Primary Bound | CPU + Small Cache (4KB) | Pure CPU Compute | Memory + CPU + Threads |
| GPU Resistance | Moderate | Very Low | Extreme (Configurable RAM) |
| Side-Channel Defense | Good | High | Immune (Argon2id hybrid) |
| Max Password Length | 72 bytes | Arbitrary | Arbitrary |
| OWASP 2026 Rank | Secondary Recommended | Acceptable Legacy | Top Recommended |
When to keep Bcrypt:
- If your runtime environment (e.g. AWS Lambda, Edge Workers, legacy enterprise Node/Python/Java) lacks native C bindings for Argon2.
- If you have existing user tables already hashed with
$2b$.
7. Practical Node.js & TypeScript Implementation
Below is a production-grade authentication utility with automatic cost migration:
import bcrypt from 'bcrypt';
const RECOMMENDED_ROUNDS = 12;
export interface PasswordVerificationResult {
isValid: boolean;
needsRehash: boolean;
}
/**
* Hash a password with modern bcrypt rounds
*/
export async function hashPassword(plainPassword: string): Promise<string> {
const salt = await bcrypt.genSalt(RECOMMENDED_ROUNDS, 'b');
return bcrypt.hash(plainPassword, salt);
}
/**
* Verify password and detect if the stored hash uses outdated rounds
*/
export async function verifyPassword(
plainPassword: string,
storedHash: string
): Promise<PasswordVerificationResult> {
const isValid = await bcrypt.compare(plainPassword, storedHash);
if (!isValid) {
return { isValid: false, needsRehash: false };
}
// Extract cost from $2b$10$...
const parts = storedHash.split('$');
const currentRounds = parts.length > 2 ? parseInt(parts[2], 10) : 0;
const needsRehash = currentRounds < RECOMMENDED_ROUNDS;
return { isValid: true, needsRehash };
}
Summary & Checklist for Developers
- Use
$2b$: Never generate$2a$or raw$2$hashes in new deployments. - Set Cost $\ge 12$: Test on your production hardware so that verification takes between 75ms and 250ms.
- Guard Against the 72-Byte Truncation: Validate password byte lengths, or apply a pre-hashing step (SHA-256) for unrestricted length fields.
- Leverage Transparent Re-Hashing: When users log in, inspect their stored rounds. If below the current security standard, seamlessly hash with updated rounds and persist to the database.