Skip to content
ExponaBLOG
PYTHON BACKEND DEVELOPMENT · DAY 019 / 100

FastAPI authentication with JWT: lessons from production

OAuth2 password flow and bearer tokens.

Expona Admin8 min read

FastAPI authentication with JWT: lessons from production

Every backend system that handles user data needs authentication, but getting it right in production is harder than most tutorials suggest. We've seen teams rush token expiry handling, leak secrets in logs, and store passwords in plain text—each mistake teaching expensive lessons. This article walks through FastAPI authentication with JWT: lessons from production, covering OAuth2 password flow and bearer tokens with the patterns we've refined across e-commerce, SaaS, and trading platforms.

Why JWT authentication matters in production

Token-based authentication scales better than session cookies when you're running multiple backend instances or serving mobile clients. JWT tokens are stateless—the server doesn't need to look up a session table on every request—but that same property means you need to think carefully about revocation and expiry. We'll build a system that balances security, scalability, and the ability to invalidate tokens when needed.

Hash passwords with passlib or argon2-cffi

The first rule: never store plain text passwords. Ever. When a user registers or changes their password, hash it using a modern algorithm. passlib with argon2-cffi is the production standard because Argon2 resists GPU and ASIC attacks far better than bcrypt or scrypt.

python
from passlib.context import CryptContext
from pydantic import BaseModel, EmailStr

pwd_context = CryptContext(schemes=["argon2"], deprecated="auto")

class UserCreate(BaseModel):
    email: EmailStr
    password: str

def hash_password(password: str) -> str:
    return pwd_context.hash(password)

def verify_password(plain: str, hashed: str) -> bool:
    return pwd_context.verify(plain, hashed)

# During user registration
def register_user(db, user_data: UserCreate):
    hashed = hash_password(user_data.password)
    user = User(email=user_data.email, hashed_password=hashed)
    db.add(user)
    db.commit()
    return user

Argon2 is deliberately slow—it takes 100+ milliseconds per hash on modern hardware. That's intentional: it makes brute-force attacks impractical. During login, you call verify_password to check if the provided password matches the stored hash. Never compare hashes directly or use string equality; the crypto library handles timing-safe comparison.

One practical note: if you're migrating legacy passwords, hash them on first login and store the old hash temporarily, then delete it. Never write a script that reads plain text passwords from logs or backups—if you find them, rotate them immediately and consider it a breach.

Issue tokens—sign a short-lived JWT with the user ID

After password verification, create a JWT containing the user ID and an expiry time. Keep the lifetime short—15 minutes is typical for access tokens. The idea is that if a token leaks, the window of exposure is narrow.

python
from datetime import datetime, timedelta, timezone
import jwt
from typing import Optional

SECRET_KEY = "your-secret-key-keep-it-safe"  # Use environment variable
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 15

def create_access_token(user_id: int, expires_delta: Optional[timedelta] = None) -> str:
    to_encode = {"sub": str(user_id)}
    
    if expires_delta:
        expire = datetime.now(timezone.utc) + expires_delta
    else:
        expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
    
    to_encode.update({"exp": expire})
    encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
    return encoded_jwt

# Usage during login
from fastapi import HTTPException, status

@app.post("/token")
async def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends()):
    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=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
    
    access_token = create_access_token(user.id)
    return {"access_token": access_token, "token_type": "bearer"}

The sub claim holds the user ID as a string. The exp claim is a Unix timestamp—jwt.encode handles conversion automatically. Always use UTC and timezone-aware datetimes to avoid DST bugs.

Critical: Store SECRET_KEY in environment variables or a secrets manager, never in code. Rotate it every 90 days if you suspect exposure. Use ALGORITHM = "HS256" for symmetric signing (you have one secret) or RS256 if you need asymmetric keys (public key for verification, private key for signing)—RS256 is better for microservices where multiple services need to verify tokens.

Protect routes with get_current_user dependency

FastAPI's dependency injection system makes it clean to verify tokens on protected routes. Create a dependency that extracts the token from the Authorization header, decodes it, and raises an exception if it's invalid or expired.

python
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthCredentials
import jwt
from jwt import PyJWTError

security = HTTPBearer()

def get_current_user(credentials: HTTPAuthCredentials = Depends(security), db: Session = Depends()) -> User:
    token = credentials.credentials
    try:
        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
        user_id: str = payload.get("sub")
        if user_id is None:
            raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
    except PyJWTError:
        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
    
    user = db.query(User).filter(User.id == int(user_id)).first()
    if not user:
        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
    
    return user

# Protected route
@app.get("/me")
async def read_user_me(current_user: User = Depends(get_current_user)):
    return {"id": current_user.id, "email": current_user.email}

@app.post("/orders")
async def create_order(order_data: OrderCreate, current_user: User = Depends(get_current_user), db: Session = Depends()):
    order = Order(user_id=current_user.id, **order_data.dict())
    db.add(order)
    db.commit()
    return order

jwt.decode automatically validates expiry—if the token is expired, it raises an exception. We catch PyJWTError (which includes decode errors, invalid signatures, and expired tokens) and return a 401 Unauthorized. The dependency ensures every protected route gets a validated user object.

In production, this dependency runs on every request. Cache decoded tokens in memory or Redis if you're doing thousands of requests per second—decoding is fast but adds up at scale. Use a simple LRU cache or a short-lived cache (5–10 seconds) to avoid staleness issues.

Refresh tokens—rotate and store server-side for revocation

Access tokens expire quickly, so you need a way for clients to get new ones without re-entering credentials. Issue a separate refresh token with a longer expiry (days or weeks) and store it in the database. This lets you revoke all tokens for a user instantly if needed.

python
from sqlalchemy import Column, Integer, String, DateTime, Boolean

class RefreshToken(Base):
    __tablename__ = "refresh_tokens"
    
    id = Column(Integer, primary_key=True)
    user_id = Column(Integer, ForeignKey("user.id"), nullable=False)
    token_hash = Column(String, nullable=False, unique=True)
    expires_at = Column(DateTime(timezone=True), nullable=False)
    revoked = Column(Boolean, default=False)
    created_at = Column(DateTime(timezone=True), default=datetime.now(timezone.utc))

def create_tokens(user_id: int, db: Session) -> dict:
    access_token = create_access_token(user_id)
    
    # Create refresh token with longer expiry
    refresh_expires = datetime.now(timezone.utc) + timedelta(days=7)
    refresh_jwt = jwt.encode(
        {"sub": str(user_id), "exp": refresh_expires, "type": "refresh"},
        SECRET_KEY, algorithm=ALGORITHM
    )
    
    # Store hash of refresh token (not the token itself)
    refresh_hash = hash_password(refresh_jwt)
    db_token = RefreshToken(user_id=user_id, token_hash=refresh_hash, expires_at=refresh_expires)
    db.add(db_token)
    db.commit()
    
    return {
        "access_token": access_token,
        "refresh_token": refresh_jwt,
        "token_type": "bearer",
        "expires_in": 900  # 15 minutes in seconds
    }

@app.post("/refresh")
async def refresh_access_token(refresh_token: str, db: Session = Depends()):
    try:
        payload = jwt.decode(refresh_token, SECRET_KEY, algorithms=[ALGORITHM])
        if payload.get("type") != "refresh":
            raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token type")
        user_id = int(payload["sub"])
    except PyJWTError:
        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token")
    
    # Check if token is revoked
    token_hash = hash_password(refresh_token)
    db_token = db.query(RefreshToken).filter(
        RefreshToken.token_hash == token_hash,
        RefreshToken.revoked == False
    ).first()
    
    if not db_token or db_token.user_id != user_id:
        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Refresh token not found")
    
    # Revoke old token and create new pair
    db_token.revoked = True
    db.commit()
    
    return create_tokens(user_id, db)

Store the hash of the refresh token, not the token itself. This prevents a database breach from immediately compromising all active sessions. When a refresh request arrives, hash the incoming token and look it up in the database.

Mark tokens as revoked when the user logs out or when they're used to get a new pair. Some teams rotate refresh tokens on every use (the code above does this), so a leaked old refresh token becomes useless quickly. You can also implement a blacklist cleanup job that deletes expired tokens older than a few days.

Common mistakes

  • Storing secrets in code: We've seen SECRET_KEY = "hardcoded-secret" in repositories. Use environment variables, and rotate the key every 90 days minimum.
  • Long access token lifetimes: 1-hour or 1-day access tokens mean a leaked token stays valid too long. Keep them under 30 minutes; use refresh tokens for longer sessions.
  • No token revocation mechanism: If you can't revoke tokens, a compromised key means all issued tokens are permanently valid. Store refresh tokens in the database so you can invalidate them.
  • Logging tokens: Never log full tokens in application or server logs. If you must log, truncate to the first 10 characters and the last 5.
  • Forgetting to validate token type: If you issue both access and refresh tokens with the same structure, always include a "type" claim. A refresh token should never work as an access token.
  • Ignoring clock skew: If servers are slightly out of sync, a token might appear expired on one machine but valid on another. Add a 10–30 second skew tolerance in production.
  • No HTTPS in production: Tokens travel in the Authorization header. Without HTTPS, they're stolen in transit. Always enforce TLS for any endpoint that issues or accepts tokens.

Key takeaways

  • Hash passwords with Argon2 (via passlib) and store hashes, never plain text. Verify on login with timing-safe comparison.
  • Issue short-lived access tokens (15 minutes) signed with a strong secret. Include user ID in the sub claim and expiry in the exp claim.
  • Use FastAPI dependencies to validate tokens on protected routes. Decode the JWT, check expiry, and return 401 if invalid.
  • Store refresh tokens as hashed values in the database. Rotate them on each use and revoke old tokens for immediate session invalidation.
  • Rotate your secret key every 90 days, use environment variables, and enforce HTTPS on all auth endpoints.
  • Cache token verification at high throughput, but keep the cache window short (5–10 seconds) to catch revocations quickly.
  • Always add a token type claim when issuing different token types, so refresh tokens can't be misused as access tokens.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles