Skip to Content

Bcrypt Password Hashing with passlib[bcrypt]

Data Flow

BCRYPT PASSWORD HASHING  —  Data Flow

   ┌── HASH (signup / password change) ─────────────────┐
   │  CryptContext(schemes=["bcrypt"], deprecated="auto")│
   │       .hash(plain)                                   │
   │            │  auto salt                              │
   │            ▼                                         │
   │  "$2b$12$…hash…."                                     │
   │            │  60-char bcrypt format                  │
   │            ▼                                         │
   │  users.hashed_password                              │
   └──────────────────────────────────────────────────────┘

   ┌── VERIFY (login) ──────────────────────────────────┐
   │  .verify(plain, hashed)   constant-time compare    │
   │            │                                       │
   │            ▼                                       │
   │       TRUE  ──►  issue JWT                          │
   │       FALSE ──►  401 Unauthorized                  │
   │            │                                       │
   │            ▼                                       │
   │  On TRUE:                                          │
   │     if cached scheme older than current default    │
   │       → rehash(plain)  behind the scenes            │
   └─────────────────────────────────────────────────────┘

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

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:

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.

🛠️ Production Failure Playbook

🚨 Failure 1 — bcrypt rounds=4 → crack in < 1 second

🚨 Failure 2 — Password stored alongside plaintext in log

Advertisement