Building Authentication in FastAPI: From Zero to Production
Authentication is the first thing that breaks in production and the last thing developers want to debug at 2 AM. Most FastAPI tutorials show you a basic JWT flow and call it done. Real authentication systems are more complex.
This guide covers a production-grade authentication system in FastAPI — password hashing, JWT tokens with refresh rotation, middleware guards, and the security pitfalls that tutorials skip.
The Problem
Every web application needs authentication, but getting it right is harder than it appears. Common mistakes include:
- Storing passwords in plaintext or with weak hashing
- Using JWTs without refresh token rotation
- Exposing sensitive data in token payloads
- Missing rate limiting on login endpoints
- No account lockout after failed attempts
A single vulnerability in your auth system compromises your entire application.
Architecture Overview
The authentication system has four layers:
- Password hashing with bcrypt and salt rounds
- JWT access tokens (short-lived, 15 minutes)
- Refresh tokens (long-lived, stored server-side)
- Middleware guards for route protection
Client → Login Endpoint → Verify Password → Issue Tokens
Client → Protected Route → Validate Access Token → Return Data
Client → Refresh Endpoint → Validate Refresh Token → Rotate Tokens
Password Hashing
Never store passwords directly. Use bcrypt with a work factor of 12:
from passlib.context import CryptContext
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
Why bcrypt over SHA-256? Bcrypt is intentionally slow. A work factor of 12 means each hash takes approximately 250ms. This makes brute-force attacks impractical while remaining fast enough for normal login flows.
JWT Token Structure
Access tokens contain minimal claims:
from datetime import datetime, timedelta, timezone
from jose import jwt
SECRET_KEY = "your-secret-key" # Use environment variable in production
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE = timedelta(minutes=15)
def create_access_token(user_id: str) -> str:
expire = datetime.now(timezone.utc) + ACCESS_TOKEN_EXPIRE
payload = {
"sub": user_id,
"exp": expire,
"type": "access"
}
return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
Key decisions:
- 15-minute expiry — short enough to limit damage from stolen tokens
- Minimal payload — only user ID, no roles or permissions (query those from the database)
- Type field — prevents refresh tokens from being used as access tokens
Refresh Token Rotation
Refresh tokens are stored in the database, not just in cookies:
import secrets
from sqlalchemy.orm import Session
def create_refresh_token(db: Session, user_id: str) -> str:
token = secrets.token_urlsafe(64)
db_token = RefreshToken(
token=hash_token(token),
user_id=user_id,
expires_at=datetime.now(timezone.utc) + timedelta(days=7),
created_at=datetime.now(timezone.utc)
)
db.add(db_token)
db.commit()
return token
def rotate_refresh_token(db: Session, old_token: str) -> tuple[str, str]:
"""Invalidate old refresh token, issue new pair."""
hashed = hash_token(old_token)
db_token = db.query(RefreshToken).filter(
RefreshToken.token == hashed,
RefreshToken.revoked == False
).first()
if not db_token or db_token.expires_at < datetime.now(timezone.utc):
raise HTTPException(status_code=401, detail="Invalid refresh token")
# Revoke old token
db_token.revoked = True
db.commit()
# Issue new pair
new_refresh = create_refresh_token(db, db_token.user_id)
new_access = create_access_token(db_token.user_id)
return new_access, new_refresh
Refresh token rotation means every time a client gets a new access token, the old refresh token is invalidated. If an attacker steals a refresh token and the legitimate user has already rotated it, the stolen token is useless.
Login Endpoint
from fastapi import APIRouter, Depends, HTTPException, Response
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
router = APIRouter()
@router.post("/auth/login")
async def login(
form_data: OAuth2PasswordRequestForm = Depends(),
db: Session = Depends(get_db)
):
user = db.query(User).filter(User.email == form_data.username).first()
if not user or not verify_password(form_data.password, user.hashed_password):
raise HTTPException(status_code=401, detail="Invalid credentials")
access_token = create_access_token(user.id)
refresh_token = create_refresh_token(db, user.id)
response = Response()
response.set_cookie(
key="refresh_token",
value=refresh_token,
httponly=True,
secure=True,
samesite="strict",
max_age=7 * 24 * 60 * 60
)
return {"access_token": access_token, "token_type": "bearer"}
Notice: the refresh token goes in an httponly cookie, not in the response body. This prevents JavaScript from accessing it, which mitigates XSS attacks.
Route Protection Middleware
from fastapi import Depends, HTTPException, Request
from fastapi.security import OAuth2PasswordBearer
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
async def get_current_user(
token: str = Depends(oauth2_scheme),
db: Session = Depends(get_db)
) -> User:
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
if payload.get("type") != "access":
raise HTTPException(status_code=401, detail="Invalid token type")
user_id = payload.get("sub")
except jwt.JWTError:
raise HTTPException(status_code=401, detail="Invalid token")
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=401, detail="User not found")
return user
Use it as a dependency on any protected route:
@router.get("/users/me")
async def get_me(current_user: User = Depends(get_current_user)):
return {"id": current_user.id, "email": current_user.email}
Rate Limiting
Without rate limiting, attackers can brute-force login credentials. Use slowapi:
from slowapi import Limiter
from slowapi.util import get_remote_address
limiter = Limiter(key_func=get_remote_address)
@router.post("/auth/login")
@limiter.limit("5/minute")
async def login(request: Request, ...):
...
Five attempts per minute per IP address. After that, return 429 Too Many Requests.
Security Checklist
Before deploying:
- Passwords hashed with bcrypt (work factor >= 12)
- Access tokens expire in 15 minutes or less
- Refresh tokens stored server-side and rotated on use
- Refresh tokens sent in httponly, secure, samesite cookies
- Login endpoint rate-limited
- Token type validated (access vs. refresh)
- No sensitive data in JWT payload
- CORS configured to allow only your frontend origin
- HTTPS enforced in production
Common Mistakes
Mistake 1: Long-lived access tokens. If you set access tokens to expire in 24 hours, a stolen token gives an attacker a full day of access. Keep them short.
Mistake 2: Storing refresh tokens in localStorage. Any XSS vulnerability exposes the refresh token. Use httponly cookies.
Mistake 3: Not rotating refresh tokens. Without rotation, a stolen refresh token works until it expires — potentially weeks.
Mistake 4: Including roles in the JWT. If roles change, the JWT still has the old roles until it expires. Query roles from the database on each request.
Takeaways
Authentication is not a solved problem — it is a spectrum of tradeoffs. Short token lifetimes improve security but increase refresh traffic. Server-side token storage adds database load but enables revocation. The system described here balances these tradeoffs for most production applications.
The most important principle: defense in depth. No single mechanism is the full solution. Layers of hashing, short token lifetimes, rotation, rate limiting, and secure cookie flags together create a system that is resilient to common attacks.