Python & APIs 2 min read 450 views

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:

  1. Tokens cannot be revoked before their expiration timestamp.
  2. Access tokens last too long, increasing exposure if intercepted.
  3. 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"}