You’ve implemented JWT authentication in your FastAPI app, everything works perfectly during testing, but then your users start complaining. “I keep getting logged out!” “The app says my session expired!” You check your logs and see hundreds of 401 Unauthorized responses with cryptic token errors. Sound familiar?
JWT token expiration is one of those issues that seems straightforward until you’re actually dealing with it in production. Tokens expire at the worst possible times—right when a user’s in the middle of filling out a form or making a purchase. Unlike session-based auth where the server keeps track of everything, with JWTs you’re managing stateless tokens that can become invalid while sitting in your user’s browser.
Symptom Description
When JWT tokens expire in FastAPI, you’ll typically encounter these issues:
Error Messages You’ll See:
# In FastAPI logs:
fastapi.exceptions.HTTPException: 401 Unauthorized
Detail: "Token has expired"
# Or from python-jose:
jose.exceptions.ExpiredSignatureError: Signature has expired
# Or from PyJWT:
jwt.exceptions.ExpiredSignatureError: Signature has expired
User-Facing Symptoms:
- Users suddenly can’t access protected routes
- API requests work for a while, then start failing with 401 errors
- Frontend shows “Session expired” or “Please log in again”
- Users get logged out unexpectedly without warning
- Intermittent authentication failures that resolve after re-login
The frustrating part? Everything works fine in development when you’re testing quickly, but in production where users might leave a tab open for hours, tokens expire left and right.
Diagnostic Steps
Before diving into fixes, let’s diagnose what’s actually happening with your tokens:
Step 1: Check Your Token Expiration Time
First, figure out how long your tokens are valid. Look for where you create JWTs:
from datetime import datetime, timedelta
from jose import jwt
# Find this in your token creation code
expire = datetime.utcnow() + timedelta(minutes=30) # This is your expiration!
If you see something like timedelta(minutes=15), your tokens only last 15 minutes. That’s really short for a user-facing app.
Step 2: Decode a Token to Check Its Claims
Grab a token from your browser’s localStorage or cookies and decode it:
from jose import jwt
token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." # Your actual token
payload = jwt.decode(token, options={"verify_signature": False})
print(payload)
# You'll see something like:
# {
# "sub": "user@example.com",
# "exp": 1707667200, # Expiration timestamp
# "iat": 1707665400 # Issued at timestamp
# }
The exp field is a Unix timestamp. Convert it to check when it expires:
from datetime import datetime
exp_time = datetime.fromtimestamp(payload["exp"])
print(f"Token expires at: {exp_time}")
print(f"That's in {(exp_time - datetime.now()).total_seconds() / 60} minutes")
Step 3: Reproduce the Error
Try to trigger the expiration:
import requests
import time
# Get a fresh token
response = requests.post("http://localhost:8000/login",
json={"username": "test", "password": "test"})
token = response.json()["access_token"]
# Wait for it to expire (or manually create an expired token for testing)
time.sleep(1800) # Wait 30 minutes if that's your expiration time
# Try using the expired token
headers = {"Authorization": f"Bearer {token}"}
response = requests.get("http://localhost:8000/protected", headers=headers)
print(response.status_code) # Should be 401
print(response.json()) # Should show "Token has expired"
Now that you know what’s happening, let’s fix it.
Cause #1: Token Expiration Time Too Short
The Problem:
You set your JWT expiration to something really short like 5 or 15 minutes because you heard “short-lived tokens are more secure.” Technically true, but if you don’t have a refresh mechanism, users will get logged out constantly.
The Fix:
Balance security with user experience. Here’s a practical approach:
from datetime import datetime, timedelta
from jose import jwt, JWTError
from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from pydantic import BaseModel
app = FastAPI()
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="login")
# Configuration
SECRET_KEY = "your-secret-key-here" # Use environment variable in production
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 60 # 1 hour - reasonable for most apps
class User(BaseModel):
username: str
email: str
def create_access_token(data: dict):
to_encode = data.copy()
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
def verify_token(token: str = Depends(oauth2_scheme)):
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username: str = payload.get("sub")
if username is None:
raise credentials_exception
return payload
except JWTError:
raise credentials_exception
@app.post("/login")
def login(username: str, password: str):
# Verify credentials (simplified for example)
if username == "test" and password == "test":
access_token = create_access_token(data={"sub": username})
return {"access_token": access_token, "token_type": "bearer"}
raise HTTPException(status_code=401, detail="Invalid credentials")
@app.get("/protected")
def protected_route(payload: dict = Depends(verify_token)):
return {"message": f"Hello {payload['sub']}!"}
Guidelines for Token Lifespan:
- 15 minutes: Only if you have refresh tokens (see Cause #2)
- 1 hour: Good default for most web apps
- 24 hours: Fine for internal tools or low-risk applications
- Never use 7+ days: At that point, you’re basically implementing sessions poorly
Cause #2: No Refresh Token Implementation
The Problem:
You have short-lived access tokens (which is good for security!), but when they expire, users have to log in again with their username and password. That’s terrible UX, especially on mobile apps where typing passwords is annoying.
The Fix:
Implement a refresh token system. You’ll issue two tokens:
- Access token: Short-lived (15-30 min), used for API requests
- Refresh token: Long-lived (7-30 days), used only to get new access tokens
Here’s a complete implementation:
from datetime import datetime, timedelta
from jose import jwt, JWTError
from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from pydantic import BaseModel
import secrets
app = FastAPI()
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="login")
# Configuration
SECRET_KEY = "your-secret-key"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30 # Short-lived
REFRESH_TOKEN_EXPIRE_DAYS = 7 # Long-lived
# In production, store this in Redis or a database
# This is just for demonstration
refresh_token_store = {} # {token: username}
class TokenResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
def create_access_token(username: str):
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode = {"sub": username, "exp": expire, "type": "access"}
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
def create_refresh_token(username: str):
# Create a unique refresh token
token_data = {
"sub": username,
"exp": datetime.utcnow() + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS),
"type": "refresh",
"jti": secrets.token_urlsafe(32) # Unique ID
}
token = jwt.encode(token_data, SECRET_KEY, algorithm=ALGORITHM)
# Store it (use Redis or database in production)
refresh_token_store[token] = username
return token
def verify_access_token(token: str = Depends(oauth2_scheme)):
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
if payload.get("type") != "access":
raise HTTPException(status_code=401, detail="Invalid token type")
return payload
except JWTError as e:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=f"Token validation failed: {str(e)}",
headers={"WWW-Authenticate": "Bearer"},
)
@app.post("/login", response_model=TokenResponse)
def login(username: str, password: str):
# Verify credentials (simplified)
if username != "test" or password != "test":
raise HTTPException(status_code=401, detail="Invalid credentials")
access_token = create_access_token(username)
refresh_token = create_refresh_token(username)
return TokenResponse(
access_token=access_token,
refresh_token=refresh_token
)
@app.post("/refresh", response_model=TokenResponse)
def refresh_access_token(refresh_token: str):
"""
Exchange a valid refresh token for a new access token
"""
try:
# Decode and validate refresh token
payload = jwt.decode(refresh_token, SECRET_KEY, algorithms=[ALGORITHM])
# Check token type
if payload.get("type") != "refresh":
raise HTTPException(status_code=401, detail="Invalid token type")
# Check if token is in our store (not revoked)
if refresh_token not in refresh_token_store:
raise HTTPException(status_code=401, detail="Token has been revoked")
username = payload.get("sub")
# Issue new access token (optionally rotate refresh token too)
new_access_token = create_access_token(username)
return TokenResponse(
access_token=new_access_token,
refresh_token=refresh_token # Reuse same refresh token
)
except JWTError:
raise HTTPException(status_code=401, detail="Invalid or expired refresh token")
@app.get("/protected")
def protected_route(payload: dict = Depends(verify_access_token)):
return {"message": f"Hello {payload['sub']}!"}
@app.post("/logout")
def logout(refresh_token: str):
"""
Invalidate a refresh token (logout user)
"""
if refresh_token in refresh_token_store:
del refresh_token_store[refresh_token]
return {"message": "Logged out successfully"}
Frontend Implementation (JavaScript example):
// Store tokens
let accessToken = null;
let refreshToken = null;
async function login(username, password) {
const response = await fetch('/login', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({username, password})
});
const data = await response.json();
accessToken = data.access_token;
refreshToken = data.refresh_token;
// Store refresh token securely (httpOnly cookie is best)
localStorage.setItem('refresh_token', refreshToken);
}
async function apiRequest(url, options = {}) {
// Try request with current access token
options.headers = {
...options.headers,
'Authorization': `Bearer ${accessToken}`
};
let response = await fetch(url, options);
// If token expired, refresh it and retry
if (response.status === 401) {
const refreshed = await refreshAccessToken();
if (refreshed) {
// Retry original request with new token
options.headers['Authorization'] = `Bearer ${accessToken}`;
response = await fetch(url, options);
} else {
// Refresh failed, redirect to login
window.location.href = '/login';
return null;
}
}
return response;
}
async function refreshAccessToken() {
const storedRefreshToken = localStorage.getItem('refresh_token');
if (!storedRefreshToken) return false;
try {
const response = await fetch('/refresh', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({refresh_token: storedRefreshToken})
});
if (response.ok) {
const data = await response.json();
accessToken = data.access_token;
return true;
}
} catch (error) {
console.error('Token refresh failed:', error);
}
return false;
}
// Use it like this:
async function getUserData() {
const response = await apiRequest('/protected');
if (response) {
const data = await response.json();
console.log(data);
}
}
This pattern ensures users stay logged in seamlessly. When the access token expires, your frontend automatically gets a new one using the refresh token—no password required.
Cause #3: Not Handling ExpiredSignatureError Properly
The Problem:
Your token verification code catches generic exceptions or doesn’t distinguish between “token expired” and other JWT errors. This means you can’t provide helpful error messages or implement proper retry logic.
The Fix:
Handle different JWT errors explicitly:
from jose import jwt, JWTError, ExpiredSignatureError
from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
app = FastAPI()
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="login")
SECRET_KEY = "your-secret-key"
ALGORITHM = "HS256"
def verify_token_with_specific_errors(token: str = Depends(oauth2_scheme)):
"""
Verify JWT and provide specific error messages
"""
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username = payload.get("sub")
if username is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token payload invalid: missing subject",
)
return payload
except ExpiredSignatureError:
# Token has expired - client should refresh
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token has expired",
headers={
"WWW-Authenticate": "Bearer",
"X-Token-Expired": "true" # Custom header for frontend
},
)
except JWTError as e:
# Other JWT errors (invalid signature, malformed token, etc.)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=f"Invalid token: {str(e)}",
headers={"WWW-Authenticate": "Bearer"},
)
@app.get("/protected")
def protected_route(payload: dict = Depends(verify_token_with_specific_errors)):
return {"message": f"Hello {payload['sub']}!"}
The X-Token-Expired header lets your frontend know this is specifically an expiration issue, not a bad token, so it can trigger the refresh flow automatically.
Enhanced Frontend Handling:
async function apiRequest(url, options = {}) {
options.headers = {
...options.headers,
'Authorization': `Bearer ${accessToken}`
};
let response = await fetch(url, options);
if (response.status === 401) {
const tokenExpired = response.headers.get('X-Token-Expired');
if (tokenExpired === 'true') {
// Specifically a token expiration - try refresh
console.log('Access token expired, refreshing...');
const refreshed = await refreshAccessToken();
if (refreshed) {
// Retry with new token
options.headers['Authorization'] = `Bearer ${accessToken}`;
return await fetch(url, options);
}
}
// Either not an expiration or refresh failed
console.error('Authentication failed, redirecting to login');
window.location.href = '/login';
return null;
}
return response;
}
Cause #4: Clock Skew Between Client and Server
The Problem:
This one’s sneaky. Your server creates a token that’s valid for 30 minutes, but the client’s system clock is 5 minutes ahead. From the client’s perspective, the token expires 5 minutes early. Or worse, if the client clock is behind, they might see a token that appears to be “issued in the future.”
The Fix:
Add a small leeway (buffer) when validating tokens:
from jose import jwt, JWTError
from fastapi import FastAPI, Depends, HTTPException
app = FastAPI()
SECRET_KEY = "your-secret-key"
ALGORITHM = "HS256"
def verify_token_with_leeway(token: str):
"""
Verify token with clock skew tolerance
"""
try:
# Add 2-minute leeway for clock skew
payload = jwt.decode(
token,
SECRET_KEY,
algorithms=[ALGORITHM],
options={
"verify_exp": True, # Still verify expiration
"leeway": 120 # But allow 2 minutes of clock skew
}
)
return payload
except JWTError as e:
raise HTTPException(status_code=401, detail=f"Token invalid: {str(e)}")
@app.get("/protected")
def protected_route(token: str = Depends(verify_token_with_leeway)):
payload = verify_token_with_leeway(token)
return {"message": f"Hello {payload['sub']}!"}
The leeway parameter (in seconds) creates a tolerance window. A token that expired 1 minute ago will still be accepted if you have a 2-minute leeway. This prevents issues from minor clock differences without significantly weakening security.
Important: Don’t set leeway too high (keep it under 5 minutes). The point is to handle clock drift, not extend token lifetime.
Cause #5: Timezone Issues with UTC
The Problem:
You’re using datetime.now() instead of datetime.utcnow() to create token expiration times. If your server is in a different timezone than UTC (which JWT timestamps use), your tokens might expire at unexpected times.
The Fix:
Always use UTC for JWT timestamps:
from datetime import datetime, timedelta
from jose import jwt
SECRET_KEY = "your-secret-key"
ALGORITHM = "HS256"
def create_token_correct_timezone(username: str):
"""
CORRECT: Use UTC timestamps
"""
expire = datetime.utcnow() + timedelta(hours=1)
payload = {
"sub": username,
"exp": expire,
"iat": datetime.utcnow() # Issued at time
}
return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
def create_token_wrong_timezone(username: str):
"""
WRONG: Using local time can cause issues
"""
# If your server is in PST (UTC-8), this creates tokens that expire 8 hours later than expected!
expire = datetime.now() + timedelta(hours=1) # BAD!
payload = {
"sub": username,
"exp": expire
}
return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
# Example showing the difference:
token_correct = create_token_correct_timezone("user1")
token_wrong = create_token_wrong_timezone("user1")
payload_correct = jwt.decode(token_correct, SECRET_KEY, algorithms=[ALGORITHM], options={"verify_exp": False})
payload_wrong = jwt.decode(token_wrong, SECRET_KEY, algorithms=[ALGORITHM], options={"verify_exp": False})
print(f"Correct exp timestamp: {payload_correct['exp']}")
print(f"Wrong exp timestamp: {payload_wrong['exp']}")
# These will differ if your server isn't in UTC timezone!
Better Yet - Use timezone-aware datetimes:
from datetime import datetime, timedelta, timezone
def create_token_timezone_aware(username: str):
"""
BEST: Use timezone-aware datetime objects
"""
now = datetime.now(timezone.utc)
expire = now + timedelta(hours=1)
payload = {
"sub": username,
"exp": expire,
"iat": now
}
return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
This eliminates any ambiguity about timezones and prevents bugs when your code runs in different environments.
Still Not Working?
If you’ve checked all the above and still have issues, consider these edge cases:
1. Token Created Before Password Change:
Users change their password, but old tokens are still valid. Solution: Add a jti (JWT ID) claim and track revoked tokens, or include a password version number in the token.
2. Multiple Concurrent Requests: User makes several API calls at once with an about-to-expire token. Some succeed, some fail with “expired.” Solution: Implement token refresh proactively on the frontend (refresh when token has <5 minutes left).
3. Development vs Production Time Differences: Works locally but fails in production. Check if your production server’s system clock is correct:
# On your server:
date -u # Should show current UTC time
If it’s off by more than a few seconds, fix your server’s NTP configuration.
4. Token Not Being Sent: Sometimes the issue isn’t expiration at all—the frontend isn’t sending the token correctly. Check browser DevTools Network tab to verify the Authorization header is present.
For more complex authentication debugging, you might want to check out how to fix Flask request context errors which covers similar state management issues in web frameworks.
Summary Checklist
Here’s your debugging checklist when dealing with JWT expiration errors:
✅ Check token lifespan - Is it appropriate for your use case?
- 15-30 min: Only with refresh tokens
- 1 hour: Good default
- 24 hours: Fine for low-risk apps
✅ Implement refresh tokens - Essential for any user-facing app with short-lived access tokens
✅ Handle ExpiredSignatureError specifically - Provide clear error messages and custom headers
✅ Add clock skew tolerance - Use 2-minute leeway to handle minor time differences
✅ Use UTC consistently - Always use datetime.utcnow() or datetime.now(timezone.utc)
✅ Test token expiration - Create expired tokens in your test suite to verify behavior
✅ Monitor in production - Track 401 errors and expiration rates to tune your settings
When your tokens start expiring gracefully in the background and users stay logged in seamlessly, you’ll know you’ve got it right. And if you need to analyze stack traces from authentication errors during debugging, Debugly’s trace formatter can help you quickly parse and understand Python tracebacks.
JWT authentication in FastAPI doesn’t have to be painful. Get the basics right—reasonable expiration times, refresh token flow, proper error handling—and your users will never even notice tokens expiring and refreshing in the background. That’s how it should work.