· 4 min read · 785 words
Bcrypt Salt Rounds, and Where the Salt Actually Lives
Bcrypt makes its own salt and stores it inside the hash, which is why the same password never hashes the same way twice. What rounds counts, and why there is no salt column.
- bcrypt
- salt
- rounds
Two of the questions people ask most often about bcrypt have the same answer, and that answer is sitting in the hash string in plain sight. How many rounds did this use, and where is the salt kept.
$2b$12$KxajCLzND3t9uXmY3IfwmuVxnyOaW.nypPfVz0FhJqzT0AWA6GqEi
└────── salt ───────┘└────────── digest ───────────┘
└┘ rounds
└┘ versionSixty characters, four fields, one column in your database. Everything verification needs later is in there.
Rounds is an exponent
The 12 means bcrypt runs 2 to the power of 12 iterations of its key schedule, so 4,096 of them. It does not mean twelve of anything. Going from 12 to 13 doubles the work, and going from 12 to 16 multiplies it by sixteen.
Every library calls this field something different and they all mean the same number. Python names it rounds, PHP names it cost, Spring Security names it strength, and the Go package just takes an int. Pick a value by timing it on the hardware that will run it, which is what the cost factor write up goes through.
The salt is 16 bytes, and bcrypt generates it for you
Those 22 characters after the second cost marker are 16 random bytes written in the base64 alphabet bcrypt uses, which is its own and not the standard one. You will notice that the last character of a salt is nearly always one of a small handful of values. That is because 16 bytes is 128 bits and 22 base64 characters hold 132, so the final character only carries two real bits.
You do not supply the salt. The library reads from the system random source and puts the result in front of the digest. This is worth saying plainly because the API shape misleads people:
import bcrypt
# gensalt returns a full prefix, not just random bytes:
# b'$2b$12$KxajCLzND3t9uXmY3Ifwmu'
salt = bcrypt.gensalt(12)
digest = bcrypt.hashpw(b'correct horse battery staple', salt)The second argument to hashpw is named salt, so it looks like the place to pass your own value. It is really a settings string that happens to carry the salt inside it, which is why the cost lives in the same argument.
Why the same password gives a different hash every time
Hash one password twice and you get two unrelated strings. This surprises people who expect a hash to be a function of its input, which it is, except the input includes 16 random bytes that changed between the two calls.
$2b$12$qXxk2MMQG13EnuhEaMM5cOGs7Z.Xyg9xvfS9O7cSFgjfywm2bU7mG
$2b$12$gwoSfnRSgGqnriVu4p.kfO4cgQmUSg16CdXjBjKmzpGe3p7yumRQqSame password, same cost, no relationship between the two lines. That is the whole purpose. Two accounts with the same weak password produce rows that look nothing alike, so cracking one tells the attacker nothing about the other, and a precomputed table of common passwords is useless against either.
So where do I store the salt?
You do not. There is no salt column, no second field, nothing to keep in sync. Store the 60 character string and you are done.
Verification works backwards from it. The library splits the stored hash apart, takes the version, the cost and the salt out of the front, hashes the candidate password with exactly those, and compares the result against the digest it read off the end.
// One column, one value, no extra state.
const ok = await bcrypt.compare(req.body.password, user.passwordHash);The verifier on this site does the same split and shows you the pieces, which is a quick way to check what cost a hash from somewhere else was made with.
The ways people break this
- Generating one salt at deploy time and reusing it for every user. Identical passwords collide again, and an attacker builds one table that works on your whole database.
- Passing a salt from Math.random or a timestamp. The system random source is there for a reason and the library already uses it.
- Adding a salt column, then hashing the salt and password together by hand before calling bcrypt. Now there are two salts, one of which you have to keep, and no gain.
- Hashing the output of bcrypt with bcrypt again, on the theory that twice is stronger. It is not, and you have doubled your login CPU cost for nothing.
If you are appending something secret to the password before hashing, that is a pepper rather than a salt, and the two have different rules. Watch the length while you do it, because bcrypt reads only the first 72 bytes of whatever you hand it.
One number to take away
Cost 12 and the library default salt is a fine place to be. If you want to see the pieces move, the generator on the homepage prints the hash next to the time it took, and raising the cost by one visibly doubles that number.