Tutorial

FastAPI Auth: Password Authentication Done Right

Password hashing is not security — it is one layer. Here is how to build registration, login, session management, and brute-force protection in FastAPI without the mistakes that lead to credential breaches.

Tin Dang avatar
Tin Dang
A key being inserted into a reinforced lock with layers of protection visible around it

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:

AlgorithmOperations/sec (GPU)Time to Crack 8-char PasswordRecommended?
SHA-256~10 billionMinutesNo
bcrypt (cost=12)~10,000YearsYes
Argon2id~1,000DecadesYes (preferred)
scrypt~5,000YearsYes

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.

src/core/security.py
from passlib.context import CryptContext
# Argon2id primary, bcrypt fallback for legacy hashes
pwd_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_rehash

The 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

src/models/user.py
from __future__ import annotations
from datetime import datetime
from uuid import UUID, uuid4
from sqlalchemy import String, Boolean, DateTime, func
from 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 is String(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_attempts and locked_until: Brute-force protection at the database level.

Registration

src/schemas/auth.py
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: str
src/api/v1/auth.py
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from src.core.security import hash_password
from src.db.session import get_db
from src.models.user import User
from 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:

  1. Email normalization: EmailStr from Pydantic lowercases and validates the email format. Without this, User@Example.com and user@example.com could create duplicate accounts.

  2. 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.

  3. 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?

StorageXSS Vulnerable?CSRF Vulnerable?HttpOnly?
localStorageYes — any script can read itNoNo (JS-accessible by design)
HttpOnly cookieNo — invisible to JavaScriptYes — but mitigatableYes

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

src/core/sessions.py
import secrets
from datetime import datetime, timedelta, timezone
from 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, Cookie
from src.core.security import verify_password
from 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"}
FlagPurpose
httponly=TrueInvisible to document.cookie — blocks XSS token theft
secure=TrueOnly sent over HTTPS — blocks network sniffing
samesite="lax"Sent on same-site requests + top-level navigations. Blocks most CSRF.
max_age=86400Cookie 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 with secure=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.

src/middleware/csrf.py
import hashlib
import hmac
import 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:

src/middleware/rate_limit.py
from datetime import timedelta
import redis.asyncio as redis
from fastapi import Request, HTTPException, status
LOGIN_RATE_LIMIT = 20 # max attempts
LOGIN_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:

LayerScopeLimitLockout Duration
Per-accountSingle user5 attempts15 minutes
Per-IPAll accounts from one IP20 attempts15 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.

0

Next in this series

FastAPI Auth: JWT Tokens — Stateless Authentication

Continue reading