How Does bcrypt Verify a Password Without a Separate Salt Column? — Inside compare()
When you first learn about password hashing, you're taught that "a salt should be randomly generated and stored separately from the password, to defend against rainbow table attacks." But if you look at actual code that uses bcrypt, there's no separate salt column at all. The database has only a single password_hash column, yet verification at login still works correctly. The trick becomes obvious the moment you dissect the structure of a bcrypt hash string itself.
1. The exact structure of a bcrypt hash string
What bcrypt.hash() produces looks like a single string, but it's actually four segments concatenated together.
| Segment | Length | Example | Meaning |
|---|---|---|---|
| Version prefix | variable (usually 4 chars) | $2b$ | Algorithm version (2, 2a, 2b, 2x, 2y) |
| Cost factor | 2 chars + surrounding $ | 10$ | Number of 2^cost iterations (e.g. 10 → 2^10 rounds) |
| Salt | 22 chars | N9qo8uLOickgx2ZMRZoMye | Random value, base64-like encoding |
| Hash result | 31 chars | IjZAgcfl7p92ldGxad68LJZdL17lhWy | Actual output of the hashing operation |
Concatenate these four pieces and you get a 60-character string like $2b$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy. The key point is that the salt is already sitting inside this string in plain form (unencrypted). There's no need to hide or encrypt it separately — the salt's job isn't to stay secret, it's to make sure the same password produces a different hash every single time.
2. Confirming the character counts in the actual code
The Bcrypt Validator's verification logic relies on exactly this structure. The code that slices the salt out of the hash string is hash.slice(7, 29). Indices 0 through 6 ($2b$10$, 7 characters) are the version+cost prefix, and indices 7 through 28 (22 characters) are the salt. Everything from index 29 to the end (31 characters) is the hash result — and the prefix's 7 characters plus the salt's 22 characters together form the first 29 characters, which is the chunk that carries "version + cost + salt" as a whole.
3. What compare() actually does
This is where the answer to "how can verification work without a salt column" becomes clear. Here's what bcrypt.compare(inputPassword, storedHash) does internally.
- It reads the first 29 characters (version+cost+salt) straight from the stored hash string.
- It reuses those 29 characters as-is to re-hash the submitted plaintext password with the same cost and the same salt.
- It compares the newly computed hash result (corresponding to the trailing 31 characters) against the trailing 31 characters of the stored hash, as strings.
- If they match exactly, it's a match; if even a single character differs, it's a mismatch.
In other words, this isn't "comparing plaintext passwords" — it's "comparing two hash results that were computed under identical conditions." There's no need to fetch the salt from a separate store, because the salt required for verification is already embedded in the very hash string being compared.
4. The $2y$ prefix and a pitfall you'll frequently run into in practice
The version prefix slot can hold one of five values: $2, $2a$, $2b$, $2x$, and $2y$. Of these, $2y$ is what PHP's password_hash() function generates by default, so it comes up often when you try to verify a hash pulled from a PHP-based service like Laravel or WordPress using a different language's stack. The Bcrypt Validator's regex (/^\$2[abxy]?\$\d{2}\$/) is written to accept all five prefixes as valid, so pasting in a $2y$ hash from PHP verifies just fine without a format error.
Once you understand the structure, debugging "why did verification fail" gets a lot faster too. If even a single character out of the 60 gets dropped or reordered during a copy-paste, the salt reconstruction itself goes wrong and you get an unconditional mismatch — so the first thing to check on a failure isn't the password, it's whether the hash string is exactly 60 characters long.
5. How the cost factor relates to computational cost
The number in the cost slot (e.g. 10) is an internal instruction to repeat the Blowfish-based key schedule 2^cost times. Every time cost goes up by 1, the amount of computation exactly doubles — so verification that took about 100ms at cost 10 grows to roughly 4x (about 400ms) at cost 12. Because this value is also stored inside the hash string itself, even if you raise the cost setting in your database later, existing hashes that were already stored continue to verify correctly against the exact cost they were created with — which is why you can gradually roll out a higher cost for new signups without needing a full migration.
Frequently Asked Questions
Q. Isn't it a security problem that the salt isn't stored separately in the database?
No, it isn't. The salt's purpose is not to stay secret — it's to prevent the same password from always producing the same hash, which is what defeats rainbow table attacks. Even though the salt is exposed in plain view inside the hash string, the salt itself is random, so an attacker can't build a precomputed table that's reusable across multiple accounts.
Q. If I hash the same password twice, will the result be different each time?
Yes. A fresh random salt is generated every time, so the output of hash() is different every time. That means you can't tell whether two hash strings came from "the same password" by comparing them directly — you must always verify using the compare() function.
Q. What's the exact length of a bcrypt hash string?
A standard bcrypt hash is 60 characters long (7-character prefix + 22-character salt + 31-character hash result). A common cause of bugs is defining the database column as CHAR(60) or shorter without enough headroom — truncation during storage will make verification fail every single time.
Q. If I want to raise the cost factor later, do I have to regenerate every existing hash?
Not immediately. Existing hashes keep verifying correctly against the cost they were stored with, so the usual approach is a gradual migration: regenerate and re-store a hash at the new cost only for a given user, at the moment they successfully log in.