Learning Goals
3 minBy the end of this lesson you can:
- Hash and verify passwords with
bcryptcorrectly. - Read the bcrypt hash format and explain what each part stores.
- Tune the cost factor and understand the security/performance trade-off.
- Explain why bcrypt/argon2 beats home-grown salting, and bcrypt's gotchas.
Warm-Up · Don't Roll Your Own
5 minLast lesson's PBKDF2 store worked — but you had to manage salts, pick rounds, store them, and remember constant-time comparison. Get any detail wrong and you have a subtle hole. The professional answer: use a library built for exactly this.
bcrypt bundles all three defences into two function calls: hashpw (which generates a salt, applies a slow cost factor, and returns a self-contained hash) and checkpw (which verifies in constant time). The salt and cost are stored inside the hash string, so there's nothing extra to track. Cryptography is full of footguns — your job is to use vetted tools correctly, not to invent your own.
New Concept · bcrypt in Practice
14 minHash & verify — the whole API
import bcrypt # pip install bcrypt # REGISTER: hash a password (bcrypt generates the salt for you) password = "demo-pw".encode("utf-8") # bcrypt works on bytes hashed = bcrypt.hashpw(password, bcrypt.gensalt()) print(hashed) # b'$2b$12$....' — store THIS string (salt is inside it) # LOGIN: verify a provided password against the stored hash ok = bcrypt.checkpw("demo-pw".encode(), hashed) # True no = bcrypt.checkpw("wrong".encode(), hashed) # False
That's it. gensalt() makes a fresh random salt; hashpw combines salt + slow hashing; checkpw re-derives and compares in constant time. You store only the single hashed string — no separate salt column.
Reading the bcrypt hash format
$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewKy.fkjAY... │ │ │ │ │ │ │ └─ the hash (and the salt is the first 22 chars here) │ │ └─ salt + hash (base64-ish) │ └─ cost factor: 12 (2^12 = 4096 iterations) └─ algorithm version: 2b (bcrypt)
Everything needed to verify is self-contained: the version, the cost, the salt, and the hash — one string in one column. To verify, bcrypt reads the cost and salt out of the stored hash and re-runs the computation. Elegant and foolproof.
The cost factor — your security dial
import bcrypt, time for cost in (10, 12, 14): t = time.perf_counter() bcrypt.hashpw(b"demo", bcrypt.gensalt(rounds=cost)) print(f"cost {cost}: {time.perf_counter()-t:.3f}s")
cost 10: 0.07s cost 12: 0.28s ← good default (~quarter second) cost 14: 1.1s
Each +1 to the cost doubles the work. Pick a cost where one hash takes ~250ms on your hardware (12 is a common default). Re-tune upward every few years as machines get faster — old hashes still verify; new ones use the higher cost.
⚠️ bcrypt gotchas
- 72-byte limit. bcrypt only uses the first 72 bytes of input. Very long passwords are silently truncated. A common fix is to pre-hash with SHA-256 then bcrypt that, or use argon2 which has no such limit.
- Bytes, not str.
hashpw/checkpwtake bytes —.encode()your strings. The stored hash is bytes too; decode to store as text if your DB column is text.
argon2 — the modern alternative
bcrypt is excellent and still widely recommended. argon2 (the winner of the Password Hashing Competition, via argon2-cffi) is the current state of the art — it adds memory hardness, making GPU/ASIC cracking even harder, and has no 72-byte limit. For new projects, argon2 is a great default; bcrypt remains a perfectly safe choice.
Worked Example · A Production-Shaped Password Store
12 minGoal: rebuild Lesson 12's auth store with bcrypt — cleaner, safer, and with the "needs-rehash" check real systems use to upgrade old hashes. Passwords are invented for the demo.
import bcrypt, json from pathlib import Path DB = Path("users.json") COST = 12 # tune so hashing ~250ms def _load() -> dict: return json.loads(DB.read_text()) if DB.exists() else {} def _save(d: dict) -> None: DB.write_text(json.dumps(d, indent=2)) def register(user: str, password: str) -> None: users = _load() if user in users: raise ValueError("user exists") hashed = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt(rounds=COST)) users[user] = hashed.decode("utf-8") # store the self-contained hash _save(users) def login(user: str, password: str) -> bool: users = _load() stored = users.get(user) if not stored: # do a dummy hash so timing doesn't reveal whether the user exists bcrypt.checkpw(b"x", bcrypt.hashpw(b"x", bcrypt.gensalt(rounds=COST))) return False stored_bytes = stored.encode("utf-8") if not bcrypt.checkpw(password.encode("utf-8"), stored_bytes): return False # upgrade-on-login: if the stored cost is below our current COST, rehash if _needs_rehash(stored_bytes): register_rehash(user, password) return True def _needs_rehash(stored: bytes) -> bool: # cost is bytes 4-6 of the hash, e.g. b'$2b$12$...' cost = int(stored.split(b"$")[2]) return cost < COST def register_rehash(user: str, password: str) -> None: users = _load() users[user] = bcrypt.hashpw(password.encode(), bcrypt.gensalt(rounds=COST)).decode() _save(users) register("aisha", "demo-pw-1"); register("ben", "demo-pw-1") print("identical pw, different hashes:", _load()["aisha"] != _load()["ben"]) print("correct login:", login("aisha", "demo-pw-1")) # True print("wrong login: ", login("aisha", "nope")) # False print("unknown user: ", login("nobody", "x")) # False (constant work)
identical pw, different hashes: True correct login: True wrong login: False unknown user: False
Read the code
Compare to Lesson 12: no manual salt management, no separate salt column, no hand-written constant-time compare — bcrypt handles all of it, and the salt + cost live inside the one stored string. The two production touches are the dummy hash for unknown users (so login timing doesn't reveal whether an account exists — an enumeration defence) and upgrade-on-login (when you raise COST, old hashes silently re-hash to the new cost as users log in). This is the real shape of password storage in a serious app — and it underpins the Flask auth in Lesson 41.
Try It Yourself
13 minInvented passwords only — never real credentials.
Hash a made-up password with bcrypt, print the hash, and verify both the correct and an incorrect password. Hash the same password twice and confirm the two hashes differ (the salt).
Take a bcrypt hash and split it on $ to print the version, cost, and salt+hash parts. Write a cost_of(hash) function that returns the cost factor as an int.
Hint
def cost_of(hashed: bytes) -> int: return int(hashed.split(b"$")[2]) print(cost_of(bcrypt.hashpw(b"x", bcrypt.gensalt(rounds=12)))) # 12
Demonstrate bcrypt's 72-byte limit: hash a 100-character password, then verify with a different password that shares the first 72 bytes — it wrongly succeeds. Then fix it by pre-hashing with SHA-256 before bcrypt, and show the fix rejects the imposter.
Hint
import bcrypt, hashlib, base64 def prehash(pw: str) -> bytes: # SHA-256 → 32 bytes, base64 → 44 bytes (under 72, no truncation) return base64.b64encode(hashlib.sha256(pw.encode()).digest()) h = bcrypt.hashpw(prehash("A"*100), bcrypt.gensalt()) print(bcrypt.checkpw(prehash("A"*72 + "B"*28), h)) # False now — fixed
Mini-Challenge · Compare bcrypt vs argon2
8 minInstall argon2-cffi and build the same hash/verify with argon2's PasswordHasher. Compare: API ergonomics, hash format, the 72-byte limit (argon2 has none), and memory hardness. Write a short recommendation for when you'd pick each.
Show a sample solution
from argon2 import PasswordHasher # pip install argon2-cffi from argon2.exceptions import VerifyMismatchError ph = PasswordHasher() # sensible secure defaults hashed = ph.hash("demo-pw") # str in, str out; salt embedded print(hashed) # $argon2id$v=19$m=65536,t=3,p=4$... try: ph.verify(hashed, "demo-pw"); print("correct ✓") except VerifyMismatchError: print("wrong ✗") print("needs rehash?", ph.check_needs_rehash(hashed)) # False (current params) # Recommendation: # - argon2id: best for NEW projects — memory-hard (resists GPU/ASIC), # no 72-byte limit, clean str API. # - bcrypt: still excellent, ubiquitous, battle-tested — fine to keep. # - Either beats rolling your own. Never use plain/fast hashes.
Non-negotiables: working argon2 hash/verify, a real comparison (limits, memory hardness, API), and a when-to-use-each recommendation.
Recap
3 minbcrypt packages last lesson's three defences into two calls: hashpw(pw, gensalt(rounds=cost)) generates a salt and applies a tunable slow cost, returning a self-contained hash (version + cost + salt + hash in one string), and checkpw verifies it in constant time. Store just that one string. Tune the cost to ~250ms and raise it over the years (with upgrade-on-login). Mind the gotchas: the 72-byte truncation (pre-hash or use argon2) and bytes-not-str. For new projects, argon2id is the modern best choice (memory-hard, no length limit); either beats rolling your own — which you should never do.
Vocabulary Card
- bcrypt
- A password-hashing function with built-in salting and a tunable cost.
- cost factor
- The work-factor exponent; +1 doubles the hashing time.
- self-contained hash
- A string embedding version, cost, salt, and hash — all you store.
- argon2id
- Modern memory-hard password hash; great default for new projects.
Homework
4 minBuild a complete bcrypt (or argon2) auth store with register, login, user-enumeration defence, and upgrade-on-login. Tune the cost for your machine. Write a short note: the bcrypt hash format decoded field-by-field, your chosen cost and why, and the 72-byte gotcha with your mitigation. Keep it — Lesson 41 plugs it into a Flask app.
Sample · hash format + decisions
bcrypt hash: $2b$12$LQv3c1yqBWVHxkd0LHAkCO + 31 chars $2b$ → algorithm/version (bcrypt 2b) $12$ → cost factor 12 (2^12 = 4096 rounds) next 22 chars → the salt (base64-ish, generated by gensalt) remaining 31 → the actual hash → everything to verify is in this one string; no salt column needed. Chosen cost: 12 (≈0.28s on my laptop). I'll bump to 13 in ~2 years and let upgrade-on-login migrate old hashes transparently. 72-byte gotcha: bcrypt ignores bytes past 72, so two long passwords sharing the first 72 bytes would match. Mitigation: SHA-256 + base64 the password first (→44 bytes), then bcrypt — or use argon2, which has no length limit.
Non-negotiables: full store with enumeration defence + upgrade-on-login, decoded hash format, a justified cost, and the 72-byte mitigation.