FastAPI JWT Auth Done Properly: Rotating Refresh Tokens and Revocation
A comprehensive implementation guide for robust JWT authentication in FastAPI, covering sliding refresh tokens, token blacklisting in Redis, and declarative RBAC security dependencies.
CM
Cyrus Mwendwa
AUTHOR • ARCHITECT
Senior Product Engineer • Nairobi, Kenya
Many online tutorials for FastAPI authentication demonstrate creating a simple JWT token with a username payload and validating the signature on protected endpoints. While simple, this approach has fatal security flaws in production:
- Tokens cannot be revoked before their expiration timestamp.
- Access tokens last too long, increasing exposure if intercepted.
- No distinction between access and refresh tokens.
Here is how to design a production-grade authentication flow.
The Dual-Token Architecture
Client FastAPI Backend Redis
│ │ │
├─── POST /auth/login ─────▶│ │
│ ├── Validate credentials │
│ ├── Generate Access JWT │
│ ├── Generate Refresh Token │
│ │ & Store JTI ──────────▶│ (TTL: 7 days)
│◀── Return Token Pair ─────┤ │
│ │ │
├─── GET /protected ───────▶│ (Bearer Access JWT) │
│ (Short TTL: 15 mins) ├── Validate signature │
│◀── 200 OK ────────────────┤ │
│ │ │
├─── POST /auth/logout ────▶│ │
│ ├── Blacklist JTI ────────▶│ (SETEX jti TTL)
│◀── 200 Logged Out ────────┤ │
1. Token Generation with Unique JTI
Every token includes a unique jti (JWT ID) UUID to enable targeted revocation:
import uuid
from datetime import datetime, timedelta, timezone
import jwt
SECRET_KEY = "your-secure-random-secret"
ALGORITHM = "HS256"
def create_access_token(user_id: int, roles: list[str]) -> str:
expires = datetime.now(timezone.utc) + timedelta(minutes=15)
payload = {
"sub": str(user_id),
"roles": roles,
"type": "access",
"jti": str(uuid.uuid4()),
"exp": expires,
}
return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
2. Declarative Permission Guards
FastAPI's dependency injection allows enforcing specific roles declaratively on route handlers:
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/token")
def require_role(allowed_roles: list[str]):
def role_checker(token: str = Depends(oauth2_scheme)):
payload = verify_and_decode_token(token)
user_roles = payload.get("roles", [])
if not any(role in allowed_roles for role in user_roles):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Insufficient permissions for this resource"
)
return payload
return role_checker
@router.delete("/projects/{id}", dependencies=[Depends(require_role(["admin"]))])
async def delete_project(id: int):
return {"message": f"Project {id} deleted"}