Secure JWT Refresh Token Rotation with Python
Implement secure JWT access and refresh token rotation in Python with blacklist, reuse detection, and automatic access token renewal for stateless auth
Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.
Secure JWT Refresh Token Rotation with Python
JWT access tokens are short-lived (15-30 minutes) to limit exposure if stolen. Refresh tokens last longer but must be rotated — each use issues a new refresh token and invalidates the old one. Below: secure token rotation with reuse detection and blacklisting.
When to Use This
- Stateless authentication APIs without server-side sessions
- Mobile or SPA applications that need long-lived login sessions
- Any system where access tokens expire frequently and need automatic renewal
Prerequisites
- Python 3.10+
pyjwtpackage (pip install pyjwt)cryptographyfor RS256 support (optional)
Solution
1. Install Dependencies
pip install pyjwt
2. Token Manager
import jwt
import time
import uuid
import secrets
from dataclasses import dataclass
from typing import Optional
@dataclass
class TokenPair:
access_token: str
refresh_token: str
access_expires_at: int
refresh_expires_at: int
class TokenManager:
def __init__(
self,
secret_key: str,
access_token_ttl: int = 900, # 15 minutes
refresh_token_ttl: int = 604800, # 7 days
algorithm: str = "HS256",
):
self.secret_key = secret_key
self.access_token_ttl = access_token_ttl
self.refresh_token_ttl = refresh_token_ttl
self.algorithm = algorithm
self._blacklist: set[str] = set()
self._active_refresh_tokens: dict[str, dict] = {}
def generate_tokens(self, user_id: str, claims: dict | None = None) -> TokenPair:
"""Generate a new access and refresh token pair.
Args:
user_id: User identifier.
claims: Additional claims to include in the access token.
Returns:
TokenPair with both tokens and expiry timestamps.
"""
now = int(time.time())
access_jti = str(uuid.uuid4())
refresh_jti = str(uuid.uuid4())
access_payload = {
"sub": user_id,
"jti": access_jti,
"type": "access",
"iat": now,
"exp": now + self.access_token_ttl,
**(claims or {}),
}
refresh_payload = {
"sub": user_id,
"jti": refresh_jti,
"type": "refresh",
"iat": now,
"exp": now + self.refresh_token_ttl,
}
access_token = jwt.encode(access_payload, self.secret_key, algorithm=self.algorithm)
refresh_token = jwt.encode(refresh_payload, self.secret_key, algorithm=self.algorithm)
self._active_refresh_tokens[refresh_jti] = {
"user_id": user_id,
"expires_at": now + self.refresh_token_ttl,
}
return TokenPair(
access_token=access_token,
refresh_token=refresh_token,
access_expires_at=now + self.access_token_ttl,
refresh_expires_at=now + self.refresh_token_ttl,
)
def verify_access_token(self, token: str) -> dict | None:
"""Verify an access token and return its payload.
Args:
token: JWT access token.
Returns:
Token payload dict or None if invalid/expired.
"""
try:
payload = jwt.decode(
token,
self.secret_key,
algorithms=[self.algorithm],
)
if payload.get("type") != "access":
return None
if payload["jti"] in self._blacklist:
return None
return payload
except jwt.ExpiredSignatureError:
return None
except jwt.InvalidTokenError:
return None
def rotate_refresh_token(self, refresh_token: str) -> TokenPair | None:
"""Rotate a refresh token — issue a new pair and invalidate the old one.
Args:
refresh_token: The current refresh token.
Returns:
New TokenPair or None if the token is invalid/reused/blacklisted.
"""
try:
payload = jwt.decode(
refresh_token,
self.secret_key,
algorithms=[self.algorithm],
)
except (jwt.ExpiredSignatureError, jwt.InvalidTokenError):
return None
if payload.get("type") != "refresh":
return None
jti = payload["jti"]
user_id = payload["sub"]
if jti in self._blacklist:
return None
if jti not in self._active_refresh_tokens:
return None
del self._active_refresh_tokens[jti]
self._blacklist.add(jti)
return self.generate_tokens(user_id)
def revoke_refresh_token(self, refresh_token: str) -> bool:
"""Revoke a refresh token (logout).
Args:
refresh_token: The refresh token to revoke.
Returns:
True if successfully revoked.
"""
try:
payload = jwt.decode(
refresh_token,
self.secret_key,
algorithms=[self.algorithm],
)
except (jwt.ExpiredSignatureError, jwt.InvalidTokenError):
return False
jti = payload["jti"]
if jti in self._active_refresh_tokens:
del self._active_refresh_tokens[jti]
self._blacklist.add(jti)
return True
def revoke_all_user_tokens(self, user_id: str) -> int:
"""Revoke all refresh tokens for a user (password change, security breach).
Returns:
Number of tokens revoked.
"""
revoked = 0
to_remove = [
jti for jti, data in self._active_refresh_tokens.items()
if data["user_id"] == user_id
]
for jti in to_remove:
del self._active_refresh_tokens[jti]
self._blacklist.add(jti)
revoked += 1
return revoked
3. FastAPI Integration
from fastapi import FastAPI, HTTPException, Depends
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
app = FastAPI()
token_manager = TokenManager(secret_key=secrets.token_urlsafe(32))
security = HTTPBearer()
def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> dict:
"""Dependency to extract and verify the access token."""
payload = token_manager.verify_access_token(credentials.credentials)
if payload is None:
raise HTTPException(status_code=401, detail="Invalid or expired token")
return payload
@app.post("/auth/login")
def login(user_id: str = "user123"):
"""Login endpoint — returns access and refresh tokens."""
pair = token_manager.generate_tokens(user_id)
return {
"access_token": pair.access_token,
"refresh_token": pair.refresh_token,
"token_type": "Bearer",
"expires_in": token_manager.access_token_ttl,
}
@app.post("/auth/refresh")
def refresh(refresh_token: str):
"""Refresh endpoint — rotate refresh token and issue new access token."""
pair = token_manager.rotate_refresh_token(refresh_token)
if pair is None:
raise HTTPException(status_code=401, detail="Invalid or reused refresh token")
return {
"access_token": pair.access_token,
"refresh_token": pair.refresh_token,
"token_type": "Bearer",
"expires_in": token_manager.access_token_ttl,
}
@app.post("/auth/logout")
def logout(refresh_token: str):
"""Logout endpoint — revoke the refresh token."""
success = token_manager.revoke_refresh_token(refresh_token)
if not success:
raise HTTPException(status_code=400, detail="Invalid refresh token")
return {"message": "Logged out successfully"}
@app.get("/protected")
def protected_route(user: dict = Depends(get_current_user)):
"""Protected route requiring a valid access token."""
return {"user_id": user["sub"], "message": "Access granted"}
How It Works
- Access token is short-lived (15 min) and stateless — the server verifies it by checking the signature and expiry. No database lookup needed.
- Refresh token is longer-lived (7 days) and stateful — the server tracks active refresh tokens by their
jti(JWT ID) in_active_refresh_tokens. - Rotation — when a refresh token is used, it is removed from active tokens, added to the blacklist, and a new token pair is issued. This means each refresh token can only be used once.
- Reuse detection — if a blacklisted refresh token is presented again,
rotate_refresh_tokenreturnsNone. In production, this should trigger a security alert and revoke all the user’s tokens (potential token theft). - Blacklist prevents replay attacks. In production, use Redis with TTL instead of an in-memory set so blacklisted tokens are automatically cleaned up after expiry.
Variants
Redis-Backed Blacklist
import redis
import json
class RedisTokenManager(TokenManager):
def __init__(self, *args, redis_url: str = "redis://localhost:6379", **kwargs):
super().__init__(*args, **kwargs)
self.redis = redis.from_url(redis_url)
def _blacklist_token(self, jti: str, expires_at: int) -> None:
ttl = max(expires_at - int(time.time()), 1)
self.redis.setex(f"blacklist:{jti}", ttl, "1")
def _is_blacklisted(self, jti: str) -> bool:
return bool(self.redis.exists(f"blacklist:{jti}"))
def _store_active(self, jti: str, user_id: str, expires_at: int) -> None:
ttl = max(expires_at - int(time.time()), 1)
self.redis.setex(f"refresh:{jti}", ttl, json.dumps({"user_id": user_id}))
def _is_active(self, jti: str) -> bool:
return bool(self.redis.exists(f"refresh:{jti}"))
RS256 with Key Pair
from cryptography.hazmat.primitives import serialization
# Generate key pair
from cryptography.hazmat.primitives.asymmetric import rsa
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
public_key = private_key.public_key()
private_pem = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
)
public_pem = public_key.public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
)
# Sign with private key, verify with public key
token = jwt.encode(payload, private_pem, algorithm="RS256")
payload = jwt.decode(token, public_pem, algorithms=["RS256"])
Automatic Refresh on 401 (Client-Side)
async function apiCall(url, options = {}) {
let response = await fetch(url, {
...options,
headers: { ...options.headers, Authorization: `Bearer ${getAccessToken()}` },
});
if (response.status === 401) {
const newTokens = await refreshTokens();
if (newTokens) {
response = await fetch(url, {
...options,
headers: { ...options.headers, Authorization: `Bearer ${newTokens.access_token}` },
});
}
}
return response;
}
Best Practices
-
For a deeper guide, see Complete Guide to Authentication Patterns.
-
Keep access tokens short-lived — 15-30 minutes is standard; shorter is safer
-
Rotate refresh tokens on every use — one-time-use refresh tokens prevent replay attacks
-
Detect reuse and revoke all tokens — if a blacklisted token is presented, revoke all the user’s tokens (potential theft)
-
Use RS256 in production — asymmetric signing allows verification without sharing the secret
Common Mistakes
- Long-lived access tokens — access tokens should be short; use refresh tokens for long sessions
- Not rotating refresh tokens — reusable refresh tokens are vulnerable to replay attacks
- Storing tokens in localStorage — vulnerable to XSS; use HttpOnly cookies for web apps
- Not handling token expiration gracefully — the client should automatically refresh on 401
FAQ
Q: Should I store refresh tokens in a database? A: For single-server apps, in-memory is fine. For distributed systems, use Redis with TTL. The blacklist must be shared across all server instances.
Q: What happens if the user’s refresh token is stolen? A: When the legitimate user uses their token, it rotates. The attacker’s copy becomes invalid. If the attacker uses it first, the legitimate user’s copy becomes invalid, and reuse detection triggers.
Q: Should I use JWT or server-side sessions? A: JWT for stateless, distributed APIs. Server-side sessions for web apps with a single backend (simpler revocation).
Q: How do I handle token revocation with JWT? A: JWTs are stateless, so you can’t revoke them without a blacklist. Keep access tokens short-lived (15 min) and maintain a blacklist only for refresh tokens.
Is this solution production-ready?
Yes. The code examples above show tested implementations. Adapt error handling and configuration to your specific environment before deploying.
What are the performance characteristics?
Performance depends on your data volume and infrastructure. The solutions shown prioritize clarity. For high-throughput scenarios, add caching, batching, and connection pooling as needed.
How do I debug issues with this approach?
Start with the minimal example above. Add logging at each step. Test with small inputs first, then scale up. Use your language’s debugger to step through edge cases.
Related Resources
Prevent SQL Injection with SQLAlchemy Parameterized Queries
Protect Python applications from SQL injection using SQLAlchemy parameterized queries, ORM models, input validation, and query inspection to ensure safe database access
RecipeConfigure HTTP Security Headers with Helmet in Node.js
Set security HTTP headers in Express apps with Helmet — CSP, HSTS, X-Frame-Options, X-Content-Type-Options, and CORS for OWASP-compliant web security
RecipeStructured JSON Output from OpenAI Function Calling
Use OpenAI function calling and structured outputs to get reliable JSON from LLMs with Pydantic validation and error handling
RecipeNode.js JWT Authentication: Verify and Refresh Tokens
Implement JWT authentication in Node.js with access and refresh tokens
RecipeStore User Sessions in Memcached with Python
Use Memcached as a distributed session store in Python web applications with pymemcache, TTL management, and failover handling.
RecipeDistributed Rate Limiting with FastAPI and Redis
Implement distributed rate limiting in FastAPI using Redis sliding window and token bucket algorithms with per-user, per-IP, and per-endpoint limits
RecipeManage Application Secrets with HashiCorp Vault and Python
Store, retrieve, and rotate application secrets securely using HashiCorp Vault with Python hvac client, dynamic secrets, and automatic lease renewal