
This is Part 5 of a series. Catch up on Part 1: A Practical Crash Course, Part 2: FastAPI with a Real Database, Part 3: JWT Authentication, and Part 4: Testing FastAPI with pytest.
In Part 3, we issued a 30-minute access token. That's good for security, but users get logged out every half hour. I also mentioned that JWTs aren't revocable by default. This time, we fix both problems with refresh tokens.
1. Why two tokens?
| Access token | Refresh token | |
|---|---|---|
| Lifetime | Short (30 minutes) | Long (7 days) |
| Sent with | Every API request | Only to /refresh |
| Stored on server? | No | Yes (so it can be revoked) |
| If stolen | Useful for minutes | Dangerous, so we guard it |
The access token stays stateless and fast. The refresh token is the one we keep track of, so we can cancel it.
2. Update the token helpers
We make two changes to auth.py from Part 3: a type claim, so a refresh token can never be used as an access token, and a new function that creates refresh tokens with a unique ID (jti).
# auth.py (updated)
import uuid
from datetime import datetime, timedelta, timezone
from jose import jwt, JWTError
SECRET_KEY = "change-this-in-production" # load from env, never hardcode
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
REFRESH_TOKEN_EXPIRE_DAYS = 7
def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str:
to_encode = data.copy()
expire = datetime.now(timezone.utc) + (
expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
)
to_encode.update({"exp": expire, "type": "access"})
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
def create_refresh_token(username: str):
jti = str(uuid.uuid4())
expire = datetime.now(timezone.utc) + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
payload = {"sub": username, "jti": jti, "type": "refresh", "exp": expire}
token = jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
return token, jti, expire
def decode_token(token: str) -> dict:
# Verifies signature and expiry. The "type" claim is checked by the caller.
try:
return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
except JWTError:
raise ValueError("Invalid or expired token")
I renamed decode_access_token to decode_token, since it now decodes both kinds.
3. Store refresh tokens server-side
# models.py (addition)
from sqlalchemy import Column, Integer, String, Boolean, DateTime
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)
username = Column(String, index=True)
expires_at = Column(DateTime)
revoked = Column(Boolean, default=False)
We store only the jti, not the token itself. A leaked database row can't be turned into a working token, because the signature lives in the token, not in the row.
# schemas.py (addition)
class RefreshRequest(BaseModel):
refresh_token: str
Send the refresh token in the request body, not as a query parameter. Query strings end up in server logs.
4. Login returns both tokens
# main.py (updated login)
from auth import create_access_token, create_refresh_token, decode_token
@app.post("/login")
def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
user = db.query(models.User).filter(models.User.username == form_data.username).first()
if not user or not verify_password(form_data.password, user.hashed_password):
raise HTTPException(status_code=401, detail="Incorrect username or password")
access_token = create_access_token({"sub": user.username})
refresh_token, jti, expires_at = create_refresh_token(user.username)
db.add(models.RefreshToken(jti=jti, username=user.username, expires_at=expires_at))
db.commit()
return {
"access_token": access_token,
"refresh_token": refresh_token,
"token_type": "bearer",
}
5. The /refresh endpoint: rotation and reuse detection
Every time a refresh token is used, we revoke it and issue a brand new pair. This is called rotation. If an already-used token ever shows up again, something is wrong (it may have been stolen), so we revoke all of that user's tokens.
@app.post("/refresh")
def refresh(body: schemas.RefreshRequest, db: Session = Depends(get_db)):
try:
payload = decode_token(body.refresh_token)
except ValueError:
raise HTTPException(status_code=401, detail="Invalid refresh token")
if payload.get("type") != "refresh":
raise HTTPException(status_code=401, detail="Wrong token type")
stored = (
db.query(models.RefreshToken)
.filter(models.RefreshToken.jti == payload["jti"])
.first()
)
if not stored:
raise HTTPException(status_code=401, detail="Unknown refresh token")
if stored.revoked:
# A used token came back: possible theft. Revoke everything for this user.
db.query(models.RefreshToken).filter(
models.RefreshToken.username == stored.username
).update({"revoked": True})
db.commit()
raise HTTPException(status_code=401, detail="Refresh token reuse detected")
# Rotate: revoke the old token, issue a new pair
stored.revoked = True
new_refresh, new_jti, expires_at = create_refresh_token(payload["sub"])
db.add(models.RefreshToken(jti=new_jti, username=payload["sub"], expires_at=expires_at))
db.commit()
return {
"access_token": create_access_token({"sub": payload["sub"]}),
"refresh_token": new_refresh,
"token_type": "bearer",
}
6. Logout that actually works
@app.post("/logout")
def logout(body: schemas.RefreshRequest, db: Session = Depends(get_db)):
try:
payload = decode_token(body.refresh_token)
except ValueError:
return {"message": "Logged out"}
stored = (
db.query(models.RefreshToken)
.filter(models.RefreshToken.jti == payload.get("jti"))
.first()
)
if stored:
stored.revoked = True
db.commit()
return {"message": "Logged out"}
After logout, the refresh token is dead, so the session can't be renewed.
7. Lock down the access check
Update get_current_user from Part 3 so it rejects refresh tokens:
def get_current_user(token: str = Depends(oauth2_scheme)):
try:
payload = decode_token(token)
except ValueError:
raise HTTPException(status_code=401, detail="Could not validate credentials")
if payload.get("type") != "access":
raise HTTPException(status_code=401, detail="Wrong token type")
return payload["sub"]
8. Test it
This builds on the client fixture from Part 4.
# tests/test_refresh.py
from datetime import timedelta
from auth import create_access_token
def login_tokens(client):
client.post("/register", params={"username": "himani", "password": "secret123"})
response = client.post(
"/login", data={"username": "himani", "password": "secret123"}
)
return response.json()
def test_refresh_returns_new_tokens(client):
tokens = login_tokens(client)
response = client.post("/refresh", json={"refresh_token": tokens["refresh_token"]})
assert response.status_code == 200
assert response.json()["refresh_token"] != tokens["refresh_token"]
def test_refresh_token_cannot_be_reused(client):
tokens = login_tokens(client)
client.post("/refresh", json={"refresh_token": tokens["refresh_token"]})
second = client.post("/refresh", json={"refresh_token": tokens["refresh_token"]})
assert second.status_code == 401
def test_refresh_token_rejected_as_access_token(client):
tokens = login_tokens(client)
response = client.post(
"/items",
json={"name": "Pen", "price": 1.5},
headers={"Authorization": f"Bearer {tokens['refresh_token']}"},
)
assert response.status_code == 401
def test_expired_access_token_rejected(client):
expired = create_access_token({"sub": "himani"}, timedelta(minutes=-1))
response = client.post(
"/items",
json={"name": "Pen", "price": 1.5},
headers={"Authorization": f"Bearer {expired}"},
)
assert response.status_code == 401
The expires_delta parameter we added earlier makes the expiry test easy: we create a token that expired a minute ago instead of waiting 30 minutes.
9. Gotchas for PHP developers
- Access tokens still can't be revoked instantly. Refresh tokens fix the long-lived session, but a stolen access token stays valid until it expires. That's why we keep it short.
- Always check the
typeclaim. Without it, a long-lived refresh token could be used as an access token and bypass the whole design. - Rotation plus reuse detection is the safety net. A stolen refresh token only works once, and the legitimate user's next refresh exposes the theft.
- Where the client stores tokens matters. For browser apps, avoid
localStoragefor refresh tokens, since any script injected into the page can read it. AnHttpOnlycookie is the usual choice. - Clean up old rows. Expired and revoked tokens pile up in the table. A scheduled job (a cron task, if you're coming from PHP) that deletes expired rows keeps it small.
What's next
The API now has login, refresh, logout, and tests for all of it. Natural next steps are role-based permissions, rate limiting on /login, and running the tests automatically in CI.
How does your team handle token refresh? Let me know in the comments.
About the author
- Building production-ready AI applications with Python, FastAPI, LangChain & Agentic AI. Sharing real-world engineering lessons along the way.
Comments
Join the discussionNo comments yet. Be the first to comment.





