Skip to Content
SecurityBcrypt Password Hashing with passlib[bcrypt]

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 customer

3.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; once deprecated="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, use schemes=["argon2"].
  • Cost factor (default 12 rounds, ~250ms) — Bcrypt’s rounds log2 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’s verify is constant-time within a single hash format. Always go through verify(), 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 successful verify(); if True, 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

  1. 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 include password in extra.
  2. 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 # reuse

Verification flow

  1. User submits LOGIN form with email + password.
  2. user = await db.execute(select(Customer).where(Customer.email == email)).
  3. if not security_service.verify_password(form.password, user.hashed_password): return 401.
  4. 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.

Last updated on