Bcrypt Password Hashing with passlib[bcrypt]
What? (Concept Overview)
Bcrypt is a deliberately-slow, salted password-hashing function whose cost-factor is parameterised to keep up with Moore’s Law: as CPUs get faster, you raise the cost factor to keep the wall-clock hashing time constant. The passlib[bcrypt] library wraps the bcrypt C extension behind a stable API (hash, verify, needs_update) so swapping algorithms later is a one-liner.
Project Context
The FCA Support Agent’s SecurityService (app/services/security_service.py) is the single owner of password hashing for Customer.hashed_password. During bulk seeding (app/seed_database.py), every customer receives a single precomputed hash of password123 so the 1000-row insert completes in a sane wall-clock budget instead of (1000 × ~250ms) per bcrypt call. This is a deliberate trade-off: production creates-isolate-hash while seed amortises.
How? (Quick Reference Blocks)
3.1 SecurityService — Hash & Verify
# app/services/security_service.py
from passlib.context import CryptContext
from app.config import settings
# CryptContext wraps one or more hashing schemes.
# `bcrypt` is the default; `deprecated="auto"` lets CryptContext
# transparently rehash on verify if a future scheme is added.
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
class SecurityService:
def get_password_hash(self, plain: str) -> str:
return pwd_context.hash(plain)
def verify_password(self, plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)3.2 Production Sign-Up (per-user hash)
# typical route handler — illustrative pattern
async def signup_customer(email: str, password: str):
async with CustomerService() as svc:
hashed = SecurityService().get_password_hash(password)
customer = await svc.create_customer(email=email, hashed_password=hashed)
return customer
async def authenticate(email: str, password: str) -> "Customer | None":
async with CustomerService() as svc:
customer = await svc.repo.find_by_email(email)
if not customer:
return None
if not SecurityService().verify_password(password, customer.hashed_password):
return None
return customer3.3 Bulk-Seed Amortisation Pattern
# app/seed_database.py — compute the hash ONCE, reuse for every customer
default_hash = security.get_password_hash("password123")
customers_data = generate_customers(
count=N,
default_pwd_hash=default_hash, # ← shared hash across all rows
)Why? (Parameter Breakdown
passlib.context.CryptContext— A multi-scheme registry; oncedeprecated="auto"is on,verify()will silently rehash with the new scheme if a legacy hash is detected (needs_update()triggers a refresh). This is the version-upgrade escape hatch every long-lived password database needs."bcrypt"(default) vs"argon2"— Argon2 (PHC winner) is strictly more secure than bcrypt by 2025 standards, but bcrypt remains in the default because of its universality. If you’re greenfield, useschemes=["argon2"].- Cost factor (default 12 rounds, ~250ms) — Bcrypt’s
roundslog2 is hardcoded to 12 by default. Raising to 13 doubles CPU time; lowering below 12 weakens at-scale brute-force resistance. The salt is auto-generated per hash, so identical passwords produce different hashes by design. - Hash-then-flip in
verify_password— passlib’sverifyis constant-time within a single hash format. Always go throughverify(), never compare hash strings yourself (if hash_in_db == pwd_context.hash(plain)is BOTH slow and timing-attack vulnerable). needs_update()for grading old passwords — Call it after a successfulverify(); ifTrue, the caller’s hash uses an old cost-factor and should transparently be rehashed on next login. Without it, legacy hashes live forever.
Common Pitfalls
- Logging the plain-text password. A single accidental
logger.info("user signed up", extra={"password": plain})puts cleartext into your log shipper. Always hash BEFORE logging and never includepasswordinextra. - Sharing one salt across many users. The whole point of bcrypt is per-user random salt. If your hashing function takes a salt parameter, ALWAYS generate fresh salt — passlib does this by default; never opt out.
Real-World Interview Prep
Q1: Why use bcrypt instead of SHA-256 / SHA-3 for password hashing?
A: SHA-2/SHA-3 are fast — that’s their design goal. Password hashing needs to be slow so an attacker who steals the hash database cannot brute-force at GPU speeds. Bcrypt’s cost factor (default ~250ms per hash on a 2024 CPU) means a 1B-record dump would take ~8 GPU-years to crack, vs. hours for SHA-256. Argon2id is a better modern choice (memory-hard, GPU-resistant); bcrypt is the de facto legacy standard with battle-tested library support.
Q2: You inherited a database where every hash is SHA-256 with no salt. What’s the migration plan?
A: (1) On next successful login, re-hash the user’s password with CryptContext(["bcrypt"], deprecated=["sha256_crypt"]). The verify step will accept the legacy scheme AND mark it needs_update() so you can rehash. (2) Force-rehash on the next forced password reset (account migration, support-ticket resolution). (3) For dead accounts (no login > 90 days), bulk-rehash to a fixed bcrypt placeholder; a future login then re-establishes the real hash. (4) Communicate the breach to the security team — SHA-256 unsalted hashes are effectively cleartext.
Q3: Why is verify() constant-time within a hash format but NOT cross-format?
A: verify() short-circuits on scheme mismatch (different $2b$... prefix vs $argon2id$...). If an attacker can submit arbitrary hashes, they can time-distinguish known-weak vs strong schemes. Mitigation: always hash with the SAME scheme (e.g., always bcrypt) so cross-format timing is impossible. In a multi-scheme world, run an is_already_hashed_in_target_scheme() precheck to normalise the path so all branches are constant-time.
Top-to-Bottom Code Walkthrough (app/services/security_service.py — Password hashing)
Bcrypt is the gold standard for password hashing under FCA/PciDSS. The implementation in this project lives inside SecurityService so all auth flows use a single chokepoint.
The CryptContext
from passlib.context import CryptContext
self.bcrypt = CryptContext(schemes=["bcrypt"], deprecated="auto")schemes=["bcrypt"] declares THIS as the active scheme. deprecated="auto" is the magic: when you later add argon2 to the schemes list, existing bcrypt hashes still verify. On the first successful verification, passlib transparently rehashes the password with the new scheme.
get_password_hash(plain: str) -> str
return self.bcrypt.hash(plain)Output looks like: "$2b$12$XZh5pWz.G6y5CphEK4jJW.b8rHtVdAhKvVcWFmROAJiO5z8vWjPi6".
Format breakdown:
$2b$— algorithm version (b = bcrypt-2b).$12$— cost factor (2^12 = 4096 iterations). Each increment doubles the work.- The rest — salt + hash concatenated.
verify_password(plain: str, hashed: str) -> bool
return self.bcrypt.verify(plain, hashed)Constant-time comparison is enforced by passlib — the running time is independent of where the first byte mismatch occurs. Why this matters: in a naive == comparison, an attacker can measure how long it takes; mismatch on byte 1 returns faster than mismatch on byte 50. Constant time eliminates that side channel.
Where the cost factor comes from
Bcrypt’s default rounds is 12. After login you could log:
print(f"login took {elapsed:.0f}ms for {cost=}")If it’s <50ms on production hardware, the rounds value is too low for the secret’s required strength. If >500ms, raise the cost gracefully (rehash on next login).
Settings integration
# in app/config.py
bcrypt_rounds: int = Field(default=12, ge=4, le=15)Pydantic clamps between 4 and 15 rounds. Below 4 = insecure. Above 15 = brute-forceable but kills the login UX.
Seeding performance consideration
seed_database.py generates 100+ test users. Hashing 100× 12-round bcrypt hashes would take ~50 seconds. Trick: seed with a single precomputed hash for “password123” and reuse it across all users (dev-only!).
DEFAULT_HASH = security_service.get_password_hash("password123") # one compute
for _ in range(100):
customer.hashed_password = DEFAULT_HASH # reuseVerification flow
- User submits LOGIN form with email + password.
user = await db.execute(select(Customer).where(Customer.email == email)).if not security_service.verify_password(form.password, user.hashed_password): return 401.- On success, upgrade the hash if the cost has changed:
if security_service.bcrypt.needs_update(user.hashed_password): user.hashed_password = security_service.get_password_hash(form.password) await db.commit()
Common Pitfalls
Storing plain passwords anywhere — including logs, error messages, debug output. The verifier must NEVER log the input password.
Using bcrypt.hashpw(plain.encode(), bcrypt.gensalt()) standalone — works but loses CryptContext’s hash-upgrade magic. Always go through the context.
Re-salting on each verify is a DoS vector: an attacker submitting 1000 requests/sec causes 1000 hash computations. Rate-limit login attempts aggressively (10/min per IP).
Real-World Interview Prep
Q1: Why bcrypt over SHA-256 + salt?
A: bcrypt is adaptive — its cost factor can be increased over time as hardware gets faster. SHA-256 runs at fixed speed; an attacker with a faster GPU cracks hashes faster than you can compensate. Bcrypt’s blowfish-based design is GPU-resistant.
Q2: What’s the difference between $2a$, $2b$, $2y$?
A: They are all bcrypt but with different bug-fix histories. $2b$ is modern (good). $2a$ is older. $2y$ is PHP’s fork. Passlib understands all three; new code should always use $2b$.
Q3: How would you migrate from bcrypt to Argon2?
A: Add "argon2" to the schemes list: CryptContext(schemes=["argon2", "bcrypt"], deprecated="auto"). Old hashes still verify; on the next successful login passlib detects the scheme and rehashes with Argon2. Zero-downtime migration.