Refresh Tokens and Rotation
Implementing Refresh Tokens and Rotation — FastAPI Backend Development.
Focus: implementing refresh tokens and rotation
You've built a FastAPI app with access tokens, but soon you realize the token expires, and your users are constantly forced to log in again. Or worse, you set a long expiry to avoid that, and a stolen token becomes a long-lived security hole. This lesson solves that pain by introducing refresh tokens and rotation — a strategy that keeps sessions alive seamlessly while shrinking the window in which a leaked access token is dangerous. Let's implement it in FastAPI, step by step.
The problem this lesson solves
Short access tokens are safer, but they force users to re-authenticate constantly. Long access tokens are convenient, but they turn a single stolen token into an open door. The classic compromise — a short-lived access token (e.g., 15 minutes) plus a longer-lived refresh token (e.g., 7 days) — only works if you also protect the refresh token from reuse.
If an attacker steals a refresh token, and your app accepts it forever, the damage is huge. Refresh token rotation means every time a client uses a refresh token, you issue a new one and invalidate the old. That way, a stolen token is useless after its first use, and you can even detect a theft when the same token is reused.
Key pain points this lesson fixes:
- Users logging in too often because access tokens expire too quickly.
- Security holes from long-lived access tokens.
- Stolen refresh tokens being replayed endlessly.
- No way to log out all sessions for a user (common problem with pure JWT).
Core concept / mental model
Think of an access token as a hotel key card for your room. It expires after a short time (like checkout time) to limit damage if lost. A refresh token is the hotel's main desk — you present it to get a new key card without re-verifying your identity. Rotation is like the hotel updating your room number every time you get a new key card; the old key card becomes invalid.
In JWT terms, both tokens are signed JSON web tokens. The access token contains the user ID and claims, and is verified by any service without touching the database. The refresh token is stored server-side (e.g., in a database or Redis) with a random jti (JWT ID) so you can revoke it.
Definitions:
- Access token: short-lived (minutes), stateless, used to authorize API calls.
- Refresh token: longer-lived (days), stateful, used only at the token endpoint to get a new access token.
- Rotation: each refresh request returns a new access token AND a new refresh token; the old refresh token is invalidated.
Here's a mental diagram in words:
Client --(login)--> Server
<-- access + refresh (refresh saved in DB)
Client --(API call with access token)--> Server (validates JWT)
When access expires:
Client --(refresh token)--> /auth/refresh
Server checks token in DB, issues new pair,
deletes old refresh token entry, saves new one.
<-- new access + new refresh
How it works step by step
- User logs in with credentials. Server validates them, generates an access token (e.g., 15 min) and a refresh token (e.g., 7 days).
- Server stores the refresh token (or its
jti+ expiry + user ID) in a database table or Redis keyed byuser_id. - Client stores both tokens (ideally in HttpOnly cookies to prevent XSS theft — but local storage is common in SPAs).
- On access token expiry (HTTP 401), the client calls
/auth/refreshwith the refresh token. - Server verifies the JWT signature, checks its
jtiexists in the store, and checks if it's not expired. - Rotation: server deletes the old token from the store, creates a new access + refresh pair, saves the new refresh token, and returns both.
- If a client uses an already-used refresh token, the server detects it (token not found) and can revoke the entire family (all tokens for that user) — a security feature.
- Logout deletes the refresh token from the store; the access token still works until it expires, which is acceptable.
Key design decisions:
- Storage: a database table
refresh_tokenswith columnsid,jti,user_id,expires_at,created_at,revoked. - Refresh token payload: include
sub(user ID),exp(expiry),jti(unique ID). Do NOT include scopes or roles — those belong to access token. - Token endpoint: use OAuth2 password flow for login, and a dedicated refresh flow.
Hands-on walkthrough
Let's build a minimal but complete implementation using FastAPI, PyJWT, and SQLAlchemy. We'll assume you have a User model already. First, install dependencies:
pip install fastapi uvicorn python-jose[cryptography] sqlalchemy
1. Database model for refresh tokens
from sqlalchemy import Column, String, Boolean, DateTime, ForeignKey
from sqlalchemy.sql import func
from .database import Base
class RefreshToken(Base):
__tablename__ = "refresh_tokens"
id = Column(Integer, primary_key=True, index=True)
jti = Column(String, unique=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"))
expires_at = Column(DateTime)
created_at = Column(DateTime, server_default=func.now())
revoked = Column(Boolean, default=False)
2. Token creation and verification utilities
# security.py
from datetime import datetime, timedelta, timezone
from jose import jwt, JWTError
SECRET_KEY = "your-secret-key" # use env var in production
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 15
REFRESH_TOKEN_EXPIRE_DAYS = 7
def create_access_token(user_id: int):
payload = {
"sub": str(user_id),
"exp": datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES),
"type": "access"
}
return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
def create_refresh_token(user_id: int, jti: str):
payload = {
"sub": str(user_id),
"exp": datetime.now(timezone.utc) + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS),
"type": "refresh",
"jti": jti
}
return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
def decode_token(token: str) -> dict:
try:
return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
except JWTError:
return None
3. Refresh endpoint with rotation
# main.py
from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from uuid import uuid4
app = FastAPI()
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
def get_current_user(token: str = Depends(oauth2_scheme)):
payload = decode_token(token)
if not payload or payload.get("type") != "access":
raise HTTPException(status_code=401, detail="Invalid or expired access token")
return int(payload["sub"])
@app.post("/auth/login")
def login(form: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
# verify credentials (simplified)
user = db.query(User).filter(User.username == form.username).first()
if not user or not verify_password(form.password, user.hashed_password):
raise HTTPException(status_code=401, detail="Incorrect username or password")
access = create_access_token(user.id)
jti = str(uuid4())
refresh = create_refresh_token(user.id, jti)
db.add(RefreshToken(
jti=jti, user_id=user.id,
expires_at=datetime.now(timezone.utc) + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
))
db.commit()
return {"access_token": access, "refresh_token": refresh, "token_type": "bearer"}
@app.post("/auth/refresh")
def refresh(refresh_token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)):
payload = decode_token(refresh_token)
if not payload or payload.get("type") != "refresh":
raise HTTPException(status_code=401, detail="Invalid refresh token")
token_record = db.query(RefreshToken).filter(
RefreshToken.jti == payload["jti"]
).first()
if not token_record or token_record.revoked:
# Possible token reuse attack — revoke all tokens for this user
db.query(RefreshToken).filter(RefreshToken.user_id == payload["sub"]).update({"revoked": True})
db.commit()
raise HTTPException(status_code=401, detail="Refresh token has been revoked")
if token_record.expires_at < datetime.now(timezone.utc):
db.delete(token_record)
db.commit()
raise HTTPException(status_code=401, detail="Refresh token expired")
# Rotation: delete old, create new
db.delete(token_record)
new_jti = str(uuid4())
new_refresh = create_refresh_token(payload["sub"], new_jti)
db.add(RefreshToken(
jti=new_jti, user_id=payload["sub"],
expires_at=datetime.now(timezone.utc) + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
))
db.commit()
new_access = create_access_token(payload["sub"])
return {"access_token": new_access, "refresh_token": new_refresh, "token_type": "bearer"}
@app.post("/auth/logout")
def logout(refresh_token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)):
payload = decode_token(refresh_token)
if payload:
db.query(RefreshToken).filter(RefreshToken.jti == payload.get("jti")).delete()
db.commit()
return {"message": "Logged out"}
Expected behavior:
POST /auth/login → {"access_token": "...", "refresh_token": "..."}
POST /auth/refresh (with old refresh) → new pair; old one deleted
POST /auth/refresh (with the same old refresh again) → 401 and all tokens revoked
Compare options / when to choose what
| Approach | Pros | Cons | When to use |
|---|---|---|---|
| Pure JWT access + refresh (no rotation) | Simple, stateless | Can't revoke stolen refresh tokens | Short-lived prototypes |
| Refresh token rotation (this lesson) | Secure, detect theft | Requires DB lookup per refresh | Production apps with sensitive data |
| Refresh token in HttpOnly cookie | XSS-resistant | CSRF concerns, mobile app complexity | Web apps with server-side rendering |
| Refresh token in localStorage | Easier for SPAs, mobile | Vulnerable to XSS theft | Internal tools, less strict security |
| OAuth2 provider (Auth0, Keycloak) | Features, compliance | External dependency, cost | Enterprise apps, multi-tenant |
Recommendation: Start with rotation + HttpOnly cookies for web apps; for pure API clients, use rotation with Bearer tokens stored securely. Add token families if you need to revoke all sessions after password change.
Troubleshooting & edge cases
- Error: "Invalid refresh token" after rotation — You probably used the same refresh token twice. The old one was deleted; the client must store the new one after each refresh.
- Access token expires before client refreshes — Add an HTTP interceptor that calls
/auth/refreshon 401 and retries the request. Don't refresh on every API call. - User logs out but access token still works — Acceptable; access tokens are short-lived. To force expiry, keep a
token_versionin the DB and include it in the token claims; check it on each request. - Clock skew between servers — Use
leewayinjwt.decode(e.g.,leeway=30) to avoid premature expiry. - Database lookup fails because token isn't there — Make sure you store the refresh token at login, not just on refresh; else rotation breaks.
- Stale tokens in DB — Add a background job to delete expired tokens; otherwise the table grows unbounded.
What you learned & what's next
You learned why short access tokens are safer, how refresh tokens decouple session longevity from token lifetime, and how rotation makes stolen tokens useless after one use. You implemented a full flow: login, refresh with rotation, detection of token reuse, and logout. You also know when to choose rotation vs. other approaches.
Next lesson: Implementing role-based access control — where you'll extend your JWT payload with roles and enforce permissions on endpoints. You'll reuse the token infrastructure you built here.
Key takeaways for this lesson:
- Access tokens should be short-lived (minutes); refresh tokens long-lived (days) but protected by rotation.
- Always store refresh tokens server-side to enable revocation and reuse detection.
- On every refresh, delete the old token and issue a new pair — never keep the same refresh token twice.
- Detect token reuse by checking for missing/revoked records and revoke the whole family.
- Use HttpOnly cookies for web apps to reduce XSS risk.
- Plan for token cleanup with a scheduled job.
Practice recap
Extend the hands-on example by adding a token_version column to your user table and include it in the access token claims. On each request, verify the version matches the database — then implement a 'logout all sessions' endpoint that increments the version. Test that old access tokens are immediately invalid after a logout.
Common mistakes
- Not storing refresh tokens server-side — then you can't revoke them, making rotation pointless.
- Reusing the same refresh token after rotation (clients must update their stored refresh token after every refresh).
- Accepting refresh tokens from the Authorization header on the refresh endpoint without checking the
typeclaim, allowing access tokens to be used as refresh tokens. - Ignoring token reuse detection — if a rotated token is presented again, just returning an error without revoking the user's token family.
- Forgetting to delete expired refresh tokens, leading to a growing database table and potential performance issues.
Variations
- Use Redis instead of a relational DB to store refresh tokens with automatic TTL expiration.
- Implement token families: each refresh creates a new child token, and reuse of any token revokes the entire family.
- Store the refresh token in an HttpOnly cookie for web apps, with CSRF protection via SameSite and origin checks.
Real-world use cases
- A single-page app (SPA) keeps users logged in for days, silently refreshing access tokens in the background every 15 minutes while rotating refresh tokens for security.
- A mobile banking API uses short access tokens and rotation so that if a phone is stolen, the refresh token can be revoked, and a single reuse attempt triggers family-wide revocation and alerts the security team.
- A multi-service microservices architecture where access tokens are stateless (validated by each service) while a central auth service manages refresh token rotation in Redis for global revocation.
Key takeaways
- Refresh tokens solve the convenience-vs-security tradeoff of access token expiration.
- Rotation invalidates each used refresh token and issues a new one, limiting theft damage.
- Server-side storage of refresh tokens enables revocation and theft detection.
- Access tokens should be short-lived and stateless; refresh tokens long-lived and stateful.
- Detect token reuse by checking for unknown or revoked token IDs and take action.
- Choose rotation over simple refresh tokens when security matters and plan for token cleanup.
Keep learning
Related tutorials, quizzes, and articles for this topic.
Discussion
Questions, corrections, and tips help everyone reading this page.
0 comments
Add a comment
No comments yet — start the thread.