Understanding Password Matching with bcrypt and Salts

I've wired up bcrypt in production more times than I can count — first time was around 2015 on a Node.js backend for a med tech company. We'd grab `bcryptjs`, call `bcrypt.hash(password, 10)`, stuff the result in the DB, and call it a day. It worked. But it took me a while to really *understand* what that magic string `$2b$10$...` was doing under the hood. This page is a collection of those clarifications — the stuff I wish someone had drawn on a whiteboard for me back then. --- ## The Hash String: Self-Contained by Design A bcrypt hash looks like this: ``` $2b$10$abcdefghijklmnopqrstuu/1234567890abcdefghijklmnopqrstuvwxyz ``` It's four parts packed into one string: | Segment | Example | Meaning | |---------|---------|---------| | `$2b$` | Algorithm version | `2a`, `2b`, `2y` — minor spec revisions. `2b` is the modern standard. | | `$10$` | Cost factor | 2^10 = 1024 rounds of the Blowfish cipher. Not "amount of salt" — it's the *work factor*. Higher = slower = harder to brute force. | | First 22 chars | The salt | Random, generated fresh per password. This is why `"password"` hashed 1000 times gives 1000 different results. | | Remaining 31 chars | The hash output | The result of running password + salt through 2^cost rounds of Blowfish. | The elegant part: **there is no separate salt column in your database.** The salt is embedded in the hash string itself. When a user logs in and you call `bcrypt.compare()`, the library: 1. Parses `$2b$10$...` to extract the cost and the salt 2. Runs the candidate password + extracted salt through the same Blowfish rounds 3. Checks if the output matches the stored hash One field, self-contained, zero configuration. --- ## What Salts Actually Do The purpose of a salt is to **defeat pre-computation attacks.** If you hash passwords with plain SHA-256, an attacker with a leaked database can take the hash value and look it up in a pre-computed table to find the original password instantly. A *rainbow table* is exactly that — a giant pre-computed lookup table. Someone spends enormous compute up front to hash every common password (every word in the dictionary, every 8-character combination, etc.), then stores the results. Once they have the table, cracking a leaked hash is O(1) — `5f4dcc3b5aa765d61d8327deb882cf99` → "password" in milliseconds. Salts destroy this approach because the salt becomes part of the input. An attacker can't pre-compute `SHA-256("password")` — they'd need to pre-compute `SHA-256("password" + random_salt)` for *every possible salt value*. With a 22-character salt (128 bits of entropy), that's 2^128 tables. Not feasible. Ever. This is also why, when you see a leaked database and every single user has a different hash for the same password, that's salts working correctly. If you see two users with the same hash for the same password, that's a red flag — no salt, or a static salt. --- ## Why Slow Matters: Cost Factor and Brute Force Salts kill pre-computation (rainbow tables). The cost factor kills online brute force. SHA-256 takes about 1 microsecond to compute. At that speed, an attacker trying common passwords against your login endpoint can blast through millions of guesses. bcrypt with cost factor 10 takes roughly 100ms per hash. That's 100,000x slower per guess. A million-guess dictionary attack that would take 1 second with SHA-256 takes over 24 hours with bcrypt at cost 10. And if you bump the cost to 12 or 14, it doubles again each step. This is the other half of why we reach for bcrypt (or scrypt, or argon2) over a general-purpose hash — the deliberate slowness is a feature, not a bug. You pay the 100ms cost once per login. An attacker pays it millions of times. --- ## The Verification Flow (end to end) Here's what actually happens when a user logs in, from keystroke to match/no match: 1. User types password into an HTML `<input type="password">` field 2. Browser sends it over TLS — encrypted in transit between the URL and the server, so nobody on the wire can read it 3. Server terminates TLS, receives the plaintext candidate password (in memory, briefly) 4. Application calls `bcrypt.compare(candidatePassword, storedHash)` — usually in a database query that fetches the hash by username or email 5. The library parses the stored hash, extracts the salt and cost, runs the candidate through the same Blowfish rounds 6. Returns `true` (password matches) or `false` (it doesn't) For registration, the flow looks like: 1. User submits password via TLS 2. Server calls `bcrypt.hash(password, 10)` — this generates a random salt automatically and runs the full Blowfish key derivation 3. The resulting `$2b$10$...` string is stored in the `password_hash` column 4. The plaintext password is discarded immediately. Never logged, never stored --- ## What I've Learned The first time I wired up bcrypt, I treated it as a black box — `hash()` to save, `compare()` to check, job done. That's fine for getting things running, but understanding what's inside that `$2b$10$` string makes you appreciate the design. The salt + cost combination covers two completely different attack vectors: - **Salts** → rainbow table / pre-computation attacks - **Cost** → online brute force / dictionary attacks One hash string, both problems solved. Not bad for something that fits in a single database column.