Password Hashing and User Registration

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
Note: bcrypt intentionally runs slowly โ€” the work factor 12 means 4096 bcrypt iterations, taking ~100โ€“300ms on a modern server. This is a feature, not a bug. For an attacker trying 10 billion password guesses per second on raw SHA256 hashes, the same attack against bcrypt would take ~300,000 years. The work factor can be increased over time (from 12 to 13 to 14) as hardware gets faster, without requiring users to change their passwords โ€” just re-hash on next login with passlib’s deprecated="auto" setting.
Tip: Always use 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.
Warning: Never log passwords or password hashes. Avoid printing them in debug output, storing them in log files, or including them in error messages. bcrypt hashes are computationally difficult to crack but are still sensitive โ€” a hash database can be attacked offline. If you must store a hash in a log for debugging, immediately rotate all affected passwords and treat it as a security incident.

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

🧠 Test Yourself

Why are two bcrypt hashes of the same password different from each other, and why is this important?