Passwords must never be stored in plain text or with fast hashing algorithms like MD5 or SHA256. A database breach that exposes password hashes must not also expose the actual passwords. bcrypt is the industry standard for password hashing โ it is intentionally slow (making brute-force attacks expensive), includes a random salt (making rainbow table attacks impossible), and has a configurable work factor (making it future-proof as hardware gets faster). The passlib library provides a clean Python interface to bcrypt and other password hashing schemes.
Password Hashing with passlib
pip install passlib[bcrypt]
# app/auth/password.py
from passlib.context import CryptContext
# CryptContext handles hashing, verification, and upgrading schemes
pwd_context = CryptContext(
schemes = ["bcrypt"],
deprecated = "auto", # automatically upgrade old hashes on verify
)
def hash_password(plain_password: str) -> str:
"""Hash a plain-text password. Returns a bcrypt hash string."""
return pwd_context.hash(plain_password)
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""
Verify a plain-text password against a stored hash.
Returns True if they match, False otherwise.
"""
return pwd_context.verify(plain_password, hashed_password)
# โโ Usage โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
hash1 = hash_password("mysecretpassword")
# "$2b$12$K9Wd6GrDnM2TjflWz2xHb.5bx7YoRyLqTCTJakrQ3P8M6MZGNJbJm"
# $2b = bcrypt, $12 = work factor (2^12 = 4096 iterations)
# The hash includes the algorithm, work factor, and random salt
verify_password("mysecretpassword", hash1) # True
verify_password("wrong_password", hash1) # False
# Two hashes of the same password are DIFFERENT (different random salts):
hash2 = hash_password("mysecretpassword")
hash1 != hash2 # True โ different hashes, same password
deprecated="auto" setting.secrets.compare_digest() for comparing values where timing attacks are a concern. However, passlib’s verify() already uses constant-time comparison internally. The more important best practice is always verifying passwords even when the user is not found โ use a dummy hash comparison to prevent timing-based user enumeration: an attacker who gets “wrong password” responses in 200ms but “user not found” in 0.1ms knows which emails are registered.User Registration Endpoint
# app/routers/auth.py
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from sqlalchemy.exc import IntegrityError
from pydantic import BaseModel, Field, field_validator
from app.dependencies import get_db
from app.models.user import User
from app.models.user_profile import UserProfile
from app.auth.password import hash_password
router = APIRouter(prefix="/auth", tags=["Auth"])
class RegisterRequest(BaseModel):
email: str = Field(..., pattern=r"^[\w.+-]+@[\w-]+\.[a-zA-Z]{2,}$")
name: str = Field(..., min_length=2, max_length=100)
password: str = Field(..., min_length=8, max_length=128)
@field_validator("email")
@classmethod
def normalise_email(cls, v: str) -> str:
return v.strip().lower()
@field_validator("password")
@classmethod
def validate_password_strength(cls, v: str) -> str:
if not any(c.isupper() for c in v):
raise ValueError("Password must contain at least one uppercase letter")
if not any(c.isdigit() for c in v):
raise ValueError("Password must contain at least one digit")
return v
class UserResponse(BaseModel):
model_config = {"from_attributes": True}
id: int
email: str
name: str
role: str
@router.post("/register", response_model=UserResponse, status_code=201)
def register(data: RegisterRequest, db: Session = Depends(get_db)):
try:
user = User(
email = data.email,
name = data.name,
password_hash = hash_password(data.password),
role = "user",
)
db.add(user)
db.flush() # get user.id
# Auto-create empty profile
profile = UserProfile(user_id=user.id)
db.add(profile)
db.flush()
db.refresh(user)
return user
except IntegrityError:
db.rollback()
raise HTTPException(
status_code = status.HTTP_409_CONFLICT,
detail = "An account with this email already exists",
)
Common Mistakes
Mistake 1 โ Storing plain text password
โ Wrong โ catastrophic on data breach:
user = User(email=data.email, password=data.password) # NEVER store plain text!
โ Correct:
user = User(email=data.email, password_hash=hash_password(data.password)) # โ
Mistake 2 โ Using SHA256/MD5 for password hashing
โ Wrong โ fast hashes are crackable in seconds with GPU rigs:
import hashlib
password_hash = hashlib.sha256(password.encode()).hexdigest() # insecure!
โ Correct โ use bcrypt (slow, salted, purpose-built).
Mistake 3 โ Not returning 409 on duplicate email (leaking user existence)
โ Wrong โ error message reveals which emails are registered:
raise HTTPException(400, f"Email {data.email} is already registered") # user enumeration!
โ Correct โ generic message:
raise HTTPException(409, "An account with this email already exists") # โ
Quick Reference
| Task | Code |
|---|---|
| Install | pip install passlib[bcrypt] |
| Create context | CryptContext(schemes=["bcrypt"], deprecated="auto") |
| Hash password | pwd_context.hash(plain_password) |
| Verify password | pwd_context.verify(plain, hashed) |
| Work factor | 12 is the recommended minimum in 2025 |
| Duplicate email | Catch IntegrityError โ 409 Conflict |