passlib[bcrypt]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 │
└─────────────────────────────────────────────────────┘
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.
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.
# 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)
# 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
# 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
)
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"].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.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.logger.info("user signed up", extra={”password”: plain}) puts cleartext into your log shipper. Always hash BEFORE logging and never include password in extra.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.
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.
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.
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.
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) -> strreturn 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.verify_password(plain: str, hashed: str) -> boolreturn 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.
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).
# 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.
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
user = await db.execute(select(Customer).where(Customer.email == email)).if not security_service.verify_password(form.password, user.hashed_password): return 401.if security_service.bcrypt.needs_update(user.hashed_password):
user.hashed_password = security_service.get_password_hash(form.password)
await db.commit()
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).
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.
$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$.
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.
f"user={user.password}" accidentally logged.password: SecretStr; never log SecretStr.