LinkedIn lost 117 million passwords in 2012 because they used unsalted SHA-1 hashes. In 2016, the full dump surfaced and every single hash was cracked. The engineers who built that system knew how to hash a string — they just chose the wrong algorithm and skipped the salt. Password authentication is deceptively simple: hash, store, compare. The devil is in the details that tutorials skip.
This post builds a complete password authentication system in FastAPI: registration, login, session management, and brute-force protection. Every decision is explained so you understand why, not just how.
Why Passwords Still Matter
Despite the rise of OAuth, SSO, and passkeys, password authentication remains the default for most applications:
- Low barrier to entry: No third-party provider needed. No OAuth dance.
- Full control: You own the user database, the credential flow, and the recovery process.
- Fallback: Even SSO-enabled apps need a password-based fallback for admin accounts or when the IdP is down.
The tradeoff is responsibility. When you store passwords, you accept liability for protecting them. Get it wrong and you are the next breach headline.
The Hashing Fundamentals
Never Store Plaintext (Obviously)
If your database is compromised, every stored password is exposed. Hashing converts the password into an irreversible digest — even with the hash, an attacker cannot recover the original password.
Why Not SHA-256?
SHA-256 is a fast hash. That is its problem. A modern GPU can compute billions of SHA-256 hashes per second. An 8-character password falls in minutes.
Password hashing algorithms are intentionally slow. They are designed to make brute-force attacks computationally expensive:
| Algorithm | Operations/sec (GPU) | Time to Crack 8-char Password | Recommended? |
|---|---|---|---|
| SHA-256 | ~10 billion | Minutes | No |
| bcrypt (cost=12) | ~10,000 | Years | Yes |
| Argon2id | ~1,000 | Decades | Yes (preferred) |
| scrypt | ~5,000 | Years | Yes |
bcrypt vs. Argon2id
bcrypt has been the standard since 1999. It is battle-tested, widely supported, and safe to use today. The cost parameter controls how many iterations the algorithm performs — each increment doubles the computation time.
Argon2id won the 2015 Password Hashing Competition. It is resistant to both GPU attacks (like bcrypt) and side-channel attacks (unlike bcrypt). It has three tuning parameters: memory, iterations, and parallelism.
Use Argon2id if your platform supports it. Use bcrypt otherwise. Both are dramatically better than any general-purpose hash. For a full breakdown of how these algorithms work internally — the compression rounds, memory-hardness mechanics, and attack cost estimates — see Hashing Deep Dive: SHA, bcrypt, scrypt, and Argon2id From the Inside Out.
from passlib.context import CryptContext
# Argon2id primary, bcrypt fallback for legacy hashespwd_context = CryptContext( schemes=["argon2", "bcrypt"], default="argon2", argon2__memory_cost=65536, # 64 MB argon2__time_cost=3, # 3 iterations argon2__parallelism=4, # 4 threads bcrypt__rounds=12, # bcrypt cost factor deprecated=["bcrypt"], # auto-rehash bcrypt → argon2 on login)
def hash_password(password: str) -> str: return pwd_context.hash(password)
def verify_password(plain_password: str, hashed_password: str) -> tuple[bool, bool]: """Returns (is_valid, needs_rehash).
needs_rehash is True when the hash uses a deprecated scheme (bcrypt) or outdated parameters. Call hash_password() to upgrade. """ is_valid = pwd_context.verify(plain_password, hashed_password) needs_rehash = pwd_context.needs_update(hashed_password) if is_valid else False return is_valid, needs_rehashThe deprecated=["bcrypt"] flag is key. When a user with an old bcrypt hash logs in, needs_update() returns True. You rehash with Argon2id transparently — the user never notices, and your security improves with every login.
The User Model
from __future__ import annotationsfrom datetime import datetimefrom uuid import UUID, uuid4
from sqlalchemy import String, Boolean, DateTime, funcfrom sqlalchemy.orm import Mapped, mapped_column
from src.db.base import Base
class User(Base): __tablename__ = "users"
id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4) email: Mapped[str] = mapped_column(String(320), unique=True, index=True) hashed_password: Mapped[str] = mapped_column(String(256)) display_name: Mapped[str] = mapped_column(String(100)) is_active: Mapped[bool] = mapped_column(Boolean, default=True) is_verified: Mapped[bool] = mapped_column(Boolean, default=False) failed_login_attempts: Mapped[int] = mapped_column(default=0) locked_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now() ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), onupdate=func.now() )Notice the security-relevant columns:
hashed_password: Never the plaintext. The column isString(256)to accommodate Argon2id hashes, which are longer than bcrypt.is_active: Soft-disable without deleting the account. Disabled users cannot authenticate.is_verified: Email verification flag. You may want to restrict certain actions until verified.failed_login_attemptsandlocked_until: Brute-force protection at the database level.
Registration
from pydantic import BaseModel, EmailStr, Field
class RegisterRequest(BaseModel): email: EmailStr password: str = Field(min_length=8, max_length=128) display_name: str = Field(min_length=1, max_length=100)
class AuthResponse(BaseModel): message: str user_id: strfrom fastapi import APIRouter, Depends, HTTPException, statusfrom sqlalchemy.ext.asyncio import AsyncSession
from src.core.security import hash_passwordfrom src.db.session import get_dbfrom src.models.user import Userfrom src.schemas.auth import RegisterRequest, AuthResponse
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/register", response_model=AuthResponse, status_code=status.HTTP_201_CREATED)async def register(body: RegisterRequest, db: AsyncSession = Depends(get_db)): # Check for existing user existing = await db.execute( select(User).where(User.email == body.email) ) if existing.scalar_one_or_none(): raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="Email already registered", )
user = User( email=body.email, hashed_password=hash_password(body.password), display_name=body.display_name, ) db.add(user) await db.commit() await db.refresh(user)
return AuthResponse(message="Registration successful", user_id=str(user.id))Security notes on registration:
-
Email normalization:
EmailStrfrom Pydantic lowercases and validates the email format. Without this,User@Example.comanduser@example.comcould create duplicate accounts. -
Password length limits: Minimum 8 characters is NIST SP 800-63B guidance. Maximum 128 prevents DoS via extremely long passwords that take disproportionate hashing time.
-
Timing attacks on “email exists” checks: The 409 response reveals that an email is registered. For high-security applications, return 200 regardless and send a verification email — if the account exists, the email says “you already have an account.”
Login with Session Cookies
For server-rendered applications and traditional web apps, session cookies are the standard. The flow:
Why Cookies, Not localStorage?
| Storage | XSS Vulnerable? | CSRF Vulnerable? | HttpOnly? |
|---|---|---|---|
localStorage | Yes — any script can read it | No | No (JS-accessible by design) |
HttpOnly cookie | No — invisible to JavaScript | Yes — but mitigatable | Yes |
HttpOnly cookies cannot be read by JavaScript. An XSS attack — injected script running on your page — cannot steal the session token. The browser sends it automatically with every request.
The tradeoff: cookies are sent automatically, which means a forged request from another site (CSRF) will include the cookie. We mitigate this with SameSite and CSRF tokens.
Implementation
import secretsfrom datetime import datetime, timedelta, timezonefrom typing import Any
import redis.asyncio as redis
SESSION_TTL = timedelta(hours=24)SESSION_PREFIX = "session:"
class SessionStore: def __init__(self, redis_client: redis.Redis): self.redis = redis_client
async def create(self, user_id: str, metadata: dict[str, Any] | None = None) -> str: session_id = secrets.token_urlsafe(32) data = { "user_id": user_id, "created_at": datetime.now(timezone.utc).isoformat(), **(metadata or {}), } await self.redis.hset(f"{SESSION_PREFIX}{session_id}", mapping=data) await self.redis.expire(f"{SESSION_PREFIX}{session_id}", int(SESSION_TTL.total_seconds())) return session_id
async def get(self, session_id: str) -> dict[str, str] | None: data = await self.redis.hgetall(f"{SESSION_PREFIX}{session_id}") if not data: return None return {k.decode(): v.decode() for k, v in data.items()}
async def destroy(self, session_id: str) -> None: await self.redis.delete(f"{SESSION_PREFIX}{session_id}")
async def refresh(self, session_id: str) -> None: """Extend session TTL on activity — sliding expiration.""" await self.redis.expire(f"{SESSION_PREFIX}{session_id}", int(SESSION_TTL.total_seconds()))# src/api/v1/auth.py (continued)from fastapi import Response, Cookiefrom src.core.security import verify_passwordfrom src.core.sessions import SessionStore
@router.post("/login")async def login( body: LoginRequest, response: Response, db: AsyncSession = Depends(get_db), sessions: SessionStore = Depends(get_session_store),): # Fetch user result = await db.execute(select(User).where(User.email == body.email)) user = result.scalar_one_or_none()
if not user or not user.is_active: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
# Check account lockout if user.locked_until and user.locked_until > datetime.now(timezone.utc): raise HTTPException( status_code=status.HTTP_423_LOCKED, detail="Account temporarily locked due to too many failed attempts", )
# Verify password is_valid, needs_rehash = verify_password(body.password, user.hashed_password)
if not is_valid: user.failed_login_attempts += 1 if user.failed_login_attempts >= 5: user.locked_until = datetime.now(timezone.utc) + timedelta(minutes=15) await db.commit() raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
# Reset failed attempts on success user.failed_login_attempts = 0 user.locked_until = None
# Rehash if needed (transparent upgrade from bcrypt to argon2) if needs_rehash: user.hashed_password = hash_password(body.password)
await db.commit()
# Create session session_id = await sessions.create( user_id=str(user.id), metadata={"ip": body.client_ip, "user_agent": body.user_agent}, )
# Set cookie response.set_cookie( key="session_id", value=session_id, httponly=True, # Not accessible to JavaScript secure=True, # Only sent over HTTPS samesite="lax", # CSRF protection max_age=86400, # 24 hours path="/", )
return {"message": "Login successful"}Cookie Flags Explained
| Flag | Purpose |
|---|---|
httponly=True | Invisible to document.cookie — blocks XSS token theft |
secure=True | Only sent over HTTPS — blocks network sniffing |
samesite="lax" | Sent on same-site requests + top-level navigations. Blocks most CSRF. |
max_age=86400 | Cookie expires in 24h. Browser deletes it after. |
path="/" | Cookie valid for all paths. Restrict to /api if your backend is scoped. |
The SameSite Decision
strict: Cookie never sent on cross-site requests. Safest, but breaks legitimate flows — clicking a link from an email to your app will not send the cookie, forcing a re-login.lax: Cookie sent on top-level navigations (clicking a link) but not on embedded requests (images, iframes, AJAX). The right default for most applications.none: Cookie always sent. Required for cross-origin scenarios (embedded widgets, third-party auth). Must pair withsecure=True.
CSRF Protection
Even with SameSite=Lax, some CSRF vectors remain (e.g., top-level POST via form submission). For state-changing operations, add a CSRF token:
The double-submit pattern: the server generates a secret (stored in a cookie) and a token (derived from the secret, sent to the client). On POST/PUT/DELETE, the client sends the token in a header. The server verifies the header matches the cookie. An attacker on a different domain cannot read the cookie to extract the secret, so they cannot forge the header.
import hashlibimport hmacimport secrets
from fastapi import Request, HTTPException, status
CSRF_SECRET_COOKIE = "csrf_secret"CSRF_HEADER = "X-CSRF-Token"SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
def generate_csrf_token(secret: str) -> str: return hmac.new(secret.encode(), b"csrf", hashlib.sha256).hexdigest()
async def csrf_middleware(request: Request, call_next): if request.method in SAFE_METHODS: return await call_next(request)
secret = request.cookies.get(CSRF_SECRET_COOKIE) token = request.headers.get(CSRF_HEADER)
if not secret or not token: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="CSRF validation failed")
expected = generate_csrf_token(secret) if not hmac.compare_digest(expected, token): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="CSRF validation failed")
return await call_next(request)hmac.compare_digest is critical here. A regular == comparison short-circuits on the first mismatched character, leaking timing information. compare_digest runs in constant time.
Brute-Force Protection
We added per-account lockout in the login handler, but that only prevents targeted attacks. For distributed attacks (different accounts, same password), add rate limiting at the IP level:
from datetime import timedelta
import redis.asyncio as redisfrom fastapi import Request, HTTPException, status
LOGIN_RATE_LIMIT = 20 # max attemptsLOGIN_RATE_WINDOW = 900 # per 15-minute window
async def login_rate_limit(request: Request, redis_client: redis.Redis): client_ip = request.client.host key = f"login_rate:{client_ip}"
attempts = await redis_client.incr(key) if attempts == 1: await redis_client.expire(key, LOGIN_RATE_WINDOW)
if attempts > LOGIN_RATE_LIMIT: raise HTTPException( status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="Too many login attempts. Try again later.", headers={"Retry-After": str(LOGIN_RATE_WINDOW)}, )This gives you two layers of brute-force protection:
| Layer | Scope | Limit | Lockout Duration |
|---|---|---|---|
| Per-account | Single user | 5 attempts | 15 minutes |
| Per-IP | All accounts from one IP | 20 attempts | 15 minutes (sliding) |
Logout
@router.post("/logout")async def logout( response: Response, session_id: str = Cookie(None, alias="session_id"), sessions: SessionStore = Depends(get_session_store),): if session_id: await sessions.destroy(session_id)
response.delete_cookie( key="session_id", httponly=True, secure=True, samesite="lax", path="/", )
return {"message": "Logged out"}Both the server-side session and the client cookie must be cleared. Deleting only the cookie leaves a valid session in Redis that could be replayed if the cookie value was captured.
Password Authentication: The Tradeoffs
Strengths:
- Simple to understand and implement
- No third-party dependencies
- Full control over the user experience
Weaknesses:
- You are responsible for secure storage (breach liability)
- Users reuse passwords across sites (credential stuffing)
- No built-in multi-factor support (you must build it)
- Session storage must scale with your user base
Choose password + session auth when:
- You are building a server-rendered web application
- You need full control over the auth flow
- Your users are internal or low-scale
- You will add MFA as a second factor later
Avoid when:
- Your users already have accounts on a major provider (use OAuth/SSO instead)
- You are building an API consumed by third-party machines (use API keys)
- You are building a single-page application that calls multiple backend services (use JWT)
What’s Next
In Part 3, we trade sessions for self-contained tokens. JWTs let your authentication scale horizontally without shared session storage — but they introduce a new set of tradeoffs around token revocation and payload size. You will build a complete JWT auth system with access tokens, refresh tokens, and token rotation.