Free lesson · GenAI Application Engineering

Build JWT auth with refresh-token rotation and Redis sessions

Build a JWTAuthMiddleware class that intercepts every FastAPI request, extracts the Bearer token from the Authorization header, and validates it using PyJWT's jwt.decode() with RS256 algorithm. Implement create_access_token() and create_refresh_token() functions that encode user_id, org_id, roles, and exp claims. Build a refresh_token_rotation() endpoint at POST /auth/refresh that issues a new access/refresh pair and invalidates the old refresh token in Redis using SETEX with 7-day TTL. Create a get_current_user() FastAPI dependency that returns a UserContext Pydantic model. Store active sessions in Redis with keys session:{user_id}:{session_id} and implement logout_all_sessions() that scans and deletes all session keys for a user. Hash passwords with passlib's bcrypt scheme in verify_password() and hash_password() helper functions.

Course: Full-Stack GenAI Applications · Chapter 10 · Authentication, Safety & Guardrails

Free to read — no subscription required.

Introduction

When you ship a GenAI app that streams LLM responses over long-lived connections, a stolen bearer token is a stolen GPU budget — and an indefinite one if tokens never rotate. Teams that bolt on a long-lived API key learn the hard way that one leaked credential is one runaway inference bill, plus full access to every user's conversation history until someone notices. By the end of this lesson you'll be able to implement JWT-based authentication with short-lived access tokens, server-side refresh-token rotation in Redis, and middleware that enforces both on every authenticated request.

Key Terminology

  • RS256: RSA Signature with SHA-256 — an asymmetric JWT signing algorithm where a private key signs tokens and any service holding the matching public key can verify them, so a gateway, inference service, and guardrails service can all validate the same token without sharing a secret.
  • jti (JWT ID): A unique identifier claim (generated here via uuid4()) embedded in each token, used as the Redis key for refresh-token tracking so individual tokens can be revoked or detected as replayed.
  • TTL (time-to-live): The expiration window applied to a token or Redis entry — 15 minutes for access tokens, 7 days for refresh tokens — after which Redis auto-evicts the entry and the JWT's exp claim causes jwt.decode() to raise ExpiredSignatureError.

Concepts

Security Considerations for GenAI Authentication

Several security properties are specific to GenAI applications and deserve explicit attention:

  1. Token scope and conversation isolation — The JWT carries the user ID, but conversation-level authorization must be checked separately. A valid token should not grant access to another user's conversation history. Implement conversation ownership checks in your route handlers by verifying that the conversation.owner_id matches request.state.user_id before returning any data.

  2. Streaming connection lifetime — SSE connections for streaming LLM responses can remain open for 30+ seconds. The JWT is validated once at connection establishment. If you need to handle mid-stream token expiration, implement a grace period: allow connections established with a token that had at least 60 seconds of remaining validity to complete their current stream, even if the token expires during generation.

  3. Rate limiting by token tier — The roles claim in the JWT can encode rate-limit tiers (e.g., "tier:free", "tier:pro"). The Redis session's request_count field, combined with a sliding window counter, enforces per-tier limits. Free-tier users might be limited to 20 LLM calls per hour, while pro-tier users get 200—preventing a single free account from exhausting your GPU inference budget.

  4. Refresh token binding — In production, bind refresh tokens to the client's fingerprint (a hash of User-Agent + IP subnet). If rotate_refresh_token() detects a fingerprint mismatch, treat it as a potential theft and revoke the family. This adds a layer of defense beyond the rotation mechanism itself, particularly relevant for GenAI apps where API tokens might be extracted from browser developer tools by curious users.

Code Walkthrough

JWT Architecture for GenAI APIs

JSON Web Tokens provide stateless authentication where the token itself carries verifiable claims. For GenAI applications, this architecture solves a specific problem: streaming LLM responses over Server-Sent Events (SSE) or WebSockets require authentication at connection establishment, and re-querying a database on every chunk emission would destroy throughput. JWTs let the middleware validate the token cryptographically without a network round-trip.

The RS256 (RSA Signature with SHA-256) algorithm uses asymmetric key pairs, meaning the private key signs tokens on your auth server while any service holding the public key can verify them independently. This matters in microservice GenAI architectures where a gateway service, an inference service, and a guardrails service all need to validate the same token without sharing secrets.

  • Access Token: Short-lived (15 minutes), contains user ID, roles, and rate-limit tier. Sent with every API request in the Authorization: Bearer header.
  • Refresh Token: Longer-lived (7 days), stored in Redis with a one-time-use constraint. Used exclusively at the /auth/refresh endpoint to obtain new access tokens.
  • Token Rotation: Each refresh operation invalidates the old refresh token and issues a new pair. If an attacker replays a stolen refresh token after the legitimate user has already rotated, the server detects the reuse and invalidates the entire token family.

The following diagram illustrates the complete token lifecycle, from initial login through refresh rotation and eventual expiration:

This sequence diagram maps the RS256 JWT authentication lifecycle between a Client, FastAPI Auth Service, and Redis. The flow begins with POST /auth/login issuing an access/refresh token pair, progresses through jwt.decode() verification on protected routes like GET /conversations, and culminates in atomic refresh-token rotation—Redis GET + DELETE ensures each refresh_token is single-use with a 7-day TTL, preventing replay attacks before requests ever reach the LLM inference layer.

Code snippet mermaid
Loading diagram...
  • Lines 1-5: Defines a Mermaid sequence diagram with four participants: Client, FastAPI (labeled as Auth Service), Redis (for token storage), and LLM (for inference).
  • Lines 7-10: Depicts the login flow — the client sends credentials via POST /auth/login, FastAPI validates them and generates an RS256-signed JWT, stores the refresh token in Redis with a 7-day TTL, and returns both access and refresh tokens to the client.
  • Lines 12-14: Shows an authenticated API call — the client sends a GET /conversations request with a Bearer access token, FastAPI decodes and verifies the JWT using the RS256 public key, and returns the conversations list with a 200 status.
  • Lines 24-27: Shows an authenticated LLM inference request — the client sends a message via POST /conversations/{id}/message with the new access token, FastAPI validates the new JWT, forwards the prompt to the LLM inference service, and the LLM streams the response back to the client via Server-Sent Events (SSE).

This flow ensures that even during long-running streaming completions (which can last 30+ seconds for complex reasoning chains), the initial token validation happens once at connection time without blocking subsequent chunk emissions.

Token Generation and Validation with PyJWT

The core of the authentication system revolves around two operations: signing tokens with the private key during login, and verifying tokens with the public key on every subsequent request. The following implementation defines a JWTTokenService class that encapsulates both operations using PyJWT's jwt.encode() and jwt.decode() functions. The class accepts RSA key material at initialization and provides create_access_token(), create_refresh_token(), and verify_token() methods. Note the use of uuid4() for the jti (JWT ID) claim, which uniquely identifies each token for revocation tracking in Redis.

Code snippet python
1import jwt 2import uuid 3from datetime import datetime, timedelta, timezone 4from dataclasses import dataclass 5 6@dataclass 7class TokenPair: 8 access_token: str 9 refresh_token: str 10 token_family: str 11 12class JWTTokenService: 13 def __init__(self, private_key: str, public_key: str): 14 self._private_key = private_key 15 self._public_key = public_key 16 self.access_ttl = timedelta(minutes=15) 17 self.refresh_ttl = timedelta(days=7) 18 19 def create_token_pair(self, user_id: str, roles: list[str]) -> TokenPair: 20 family_id = str(uuid.uuid4()) 21 now = datetime.now(timezone.utc) 22 23 access_payload = { 24 "sub": user_id, 25 "roles": roles, 26 "type": "access", 27 "iat": now, 28 "exp": now + self.access_ttl, 29 "jti": str(uuid.uuid4()), 30 } 31 refresh_payload = { 32 "sub": user_id, 33 "type": "refresh", 34 "family": family_id, 35 "iat": now, 36 "exp": now + self.refresh_ttl, 37 "jti": str(uuid.uuid4()), 38 } 39 40 access_token = jwt.encode(access_payload, self._private_key, algorithm="RS256") 41 refresh_token = jwt.encode(refresh_payload, self._private_key, algorithm="RS256") 42 43 return TokenPair( 44 access_token=access_token, 45 refresh_token=refresh_token, 46 token_family=family_id, 47 ) 48 49 def verify_token(self, token: str, expected_type: str = "access") -> dict: 50 try: 51 payload = jwt.decode(token, self._public_key, algorithms=["RS256"]) 52 except jwt.ExpiredSignatureError: 53 raise AuthError("Token has expired", code=401) 54 except jwt.InvalidTokenError as exc: 55 raise AuthError(f"Invalid token: {exc}", code=401) 56 57 if payload.get("type") != expected_type: 58 raise AuthError(f"Expected {expected_type} token", code=401) 59 60 return payload
  • Lines 1–4: Import PyJWT as jwt, the uuid module for generating unique token identifiers, datetime utilities for expiration calculations, and dataclass for the TokenPair return type.
  • Lines 7–10: The TokenPair dataclass bundles both tokens with their token_family identifier, which links refresh tokens together for rotation tracking. A token family represents all refresh tokens descended from a single login event.
  • Lines 13–17: The constructor accepts PEM-formatted RSA keys as strings. Access tokens live for 15 minutes—long enough for a multi-turn conversation but short enough to limit damage from token theft. Refresh tokens live for 7 days, matching typical "remember me" session expectations.
  • Lines 53–56: Catching jwt.ExpiredSignatureError separately from jwt.InvalidTokenError allows the client to distinguish "you need to refresh" from "this token is fundamentally invalid." The type check on line 56 prevents a refresh token from being used as an access token at regular endpoints.

Redis-Backed Session Management and FastAPI Middleware Integration

While JWTs are stateless by design, refresh token rotation requires server-side state to track which tokens have been used. Redis provides the ideal backing store: its atomic GET and DELETE operations prevent race conditions during rotation, and its native TTL support automatically cleans up expired token families without a background job. The RedisSessionManager class below manages both refresh token tracking and user session metadata (active conversations, rate-limit counters). It uses redis.asyncio for non-blocking I/O compatible with FastAPI's async request handling, while store_refresh_token() writes the token's jti claim as a Redis key with the token family as its value, and rotate_refresh_token() atomically validates and replaces the old token.

The accompanying JWTAuthMiddleware class connects token verification and session management to every inbound request. It intercepts requests before they reach route handlers, extracts the Bearer token, validates it through JWTTokenService.verify_token(), loads the session, and injects the authenticated user context into FastAPI's request.state. The middleware skips validation for public paths defined in PUBLIC_PATHS.

Code snippet python
1import redis.asyncio as aioredis 2import json 3from datetime import timedelta 4from starlette.middleware.base import BaseHTTPMiddleware 5from starlette.requests import Request 6from starlette.responses import JSONResponse 7 8class RedisSessionManager: 9 def __init__(self, redis_url: str = "redis://localhost:6379/0"): 10 self._redis = aioredis.from_url(redis_url, decode_responses=True) 11 self._refresh_prefix = "refresh:" 12 self._session_prefix = "session:" 13 self._family_prefix = "family:" 14 15 async def store_refresh_token( 16 self, jti: str, family: str, user_id: str, ttl: timedelta 17 ) -> None: 18 pipe = self._redis.pipeline() 19 pipe.setex(f"{self._refresh_prefix}{jti}", ttl, json.dumps({ 20 "family": family, "user_id": user_id 21 })) 22 pipe.setex(f"{self._family_prefix}{family}", ttl, "active") 23 await pipe.execute() 24 25 async def rotate_refresh_token( 26 self, old_jti: str, old_family: str 27 ) -> bool: 28 stored = await self._redis.get(f"{self._refresh_prefix}{old_jti}") 29 if stored is None: 30 family_status = await self._redis.get( 31 f"{self._family_prefix}{old_family}" 32 ) 33 if family_status == "revoked": 34 return False # Replay attack detected 35 await self._redis.setex( 36 f"{self._family_prefix}{old_family}", 37 timedelta(days=7), 38 "revoked", 39 ) 40 return False # Token already used — revoke entire family 41 42 await self._redis.delete(f"{self._refresh_prefix}{old_jti}") 43 return True 44 45 async def create_session( 46 self, user_id: str, metadata: dict, ttl: timedelta = timedelta(hours=8) 47 ) -> str: 48 session_key = f"{self._session_prefix}{user_id}" 49 session_data = { 50 "user_id": user_id, 51 "active_conversations": [], 52 "request_count": 0, 53 **metadata, 54 } 55 await self._redis.setex(session_key, ttl, json.dumps(session_data)) 56 return session_key 57 58 async def get_session(self, user_id: str) -> dict | None: 59 data = await self._redis.get(f"{self._session_prefix}{user_id}") 60 if data is None: 61 return None 62 return json.loads(data) 63 64 async def increment_request_count(self, user_id: str) -> int: 65 session_key = f"{self._session_prefix}{user_id}" 66 raw = await self._redis.get(session_key) 67 if raw is None: 68 raise SessionExpiredError(f"No session for {user_id}") 69 session = json.loads(raw) 70 session["request_count"] += 1 71 ttl = await self._redis.ttl(session_key) 72 if ttl > 0: 73 await self._redis.setex(session_key, ttl, json.dumps(session)) 74 return session["request_count"] 75 76PUBLIC_PATHS = {"/auth/login", "/auth/register", "/health", "/docs", "/openapi.json"} 77 78class JWTAuthMiddleware(BaseHTTPMiddleware): 79 def __init__(self, app, token_service: JWTTokenService, 80 session_manager: RedisSessionManager): 81 super().__init__(app) 82 self.token_service = token_service 83 self.session_manager = session_manager 84 85 async def dispatch(self, request: Request, call_next): 86 if request.url.path in PUBLIC_PATHS: 87 return await call_next(request) 88 89 auth_header = request.headers.get("Authorization") 90 if auth_header is None or not auth_header.startswith("Bearer "): 91 return JSONResponse( 92 {"detail": "Missing or malformed Authorization header"}, 93 status_code=401, 94 ) 95 96 token = auth_header[7:] # Strip "Bearer " prefix 97 try: 98 payload = self.token_service.verify_token(token, expected_type="access") 99 except AuthError as exc: 100 return JSONResponse({"detail": exc.message}, status_code=exc.code) 101 102 user_id = payload["sub"] 103 session = await self.session_manager.get_session(user_id) 104 if session is None: 105 session = await self.session_manager.create_session( 106 user_id, metadata={"roles": payload.get("roles", [])} 107 ) 108 109 request.state.user_id = user_id 110 request.state.user_roles = payload.get("roles", []) 111 request.state.token_jti = payload.get("jti") 112 113 await self.session_manager.increment_request_count(user_id) 114 return await call_next(request)
  • Lines 1–3: The middleware inherits from Starlette's BaseHTTPMiddleware, which provides the dispatch() hook for intercepting every request/response cycle. This works identically in FastAPI since FastAPI is built on Starlette.
  • Line 6: The PUBLIC_PATHS set defines endpoints that do not require authentication. Checking membership in a set is O(1), which matters when this check runs on every single request including health checks from load balancers.
  • Lines 9–14: The constructor accepts pre-configured JWTTokenService and RedisSessionManager instances, following dependency injection. This makes testing straightforward—pass mock implementations during unit tests.
  • Lines 45–46: Incrementing the request count on every authenticated request enables per-user rate limiting at the session level. For GenAI applications, this is essential—a single LLM inference call can cost $0.01–$0.10, so unthrottled access represents a direct financial risk.

Do's and Don'ts

Do's

  1. Do use RS256 (asymmetric RSA keys) to sign JWTs — the private key stays on your auth server while every downstream service (inference, guardrails, gateway) independently verifies tokens using only the public key, eliminating shared-secret sprawl across your microservice stack.
  2. Do perform Redis GET + DELETE atomically when rotating refresh tokens — this single-use enforcement is what detects replay attacks: if an attacker presents a stolen refresh token after the legitimate user has already rotated, the old token is gone and the server can invalidate the entire token family.
  3. Do include the jti (JWT ID) claim via uuid4() in every token — without a unique per-token identifier, Redis-based revocation tracking has nothing to key on, making it impossible to invalidate individual tokens or detect reused refresh token families.

Don'ts

  1. Don't set access token TTL longer than 15 minutes for GenAI APIs — a stolen bearer token is a stolen GPU budget; longer-lived access tokens extend the blast radius of a credential leak to cover every user's conversation history until the token naturally expires.
  2. Don't re-query Redis or a database on every SSE chunk emission to re-validate the bearer token — streaming LLM responses can run 30+ seconds; validation must happen once at connection establishment via jwt.decode() with the RS256 public key, or you will destroy throughput on long-running inference streams.
  3. Don't store the refresh token in a client-accessible location that survives a POST /auth/refresh — reissuing the same refresh token across rotation calls defeats the reuse-detection mechanism in JWTTokenService; each rotation must issue a new token_family-linked pair and discard the old one immediately.

This lesson is free to read. Its 3 hands-on labs — real code, in a cloud IDE — are part of the GenAI Application Engineering subscription.

From · cancel anytime · Already a subscriber? Sign in →

More free lessons in Full-Stack GenAI Applications

All free lessons in GenAI Application Engineering