šÆ What You Will Learn
A plain JWT setup has a weak spot: the refresh token. If it's long-lived and static, a stolen one is permanent access. Rotation + reuse detection fixes that ā each refresh swaps the token, and a replayed old token instantly kills the whole session.
- Why static refresh tokens are a standing risk (see it first)
- Short access tokens + rotating refresh tokens
- Token families and storing refresh tokens hashed in the DB
- The
/refreshrotation endpoint - Reuse detection: revoke the family when a used token reappears
/logoutthat actually revokes the session
Prerequisites: you understand JWT access tokens (fastapi-jwt) and have a DB (how-to-connect-fastapi-to-postgres). This post adds the refresh half and makes it secure.
fastapi-refresh-rotation/
āāā requirements.txt
āāā app/
āāā __init__.py
āāā database.py # get_session
āāā models.py # RefreshToken (hashed, family_id, revoked)
āāā security.py # access JWT + refresh token helpers
āāā main.py # /login, /refresh, /logoutThe Problem: A Stolen Refresh Token Is Forever (See It First)
The common "JWT with refresh" setup: a short access token plus a long-lived refresh token you can trade for new access tokens. Simple ā and if that refresh token never changes, dangerous.
@app.post("/refresh")
def refresh(payload: RefreshIn):
user_id = verify_static_refresh(payload.refresh_token) # same token forever
return {"access_token": create_access_token(user_id)} # hands out access... indefinitelyAttacker steals the refresh token once (XSS, leaked log, shared device):
stolen_refresh āāāŗ /refresh āāāŗ new access token (day 1)
stolen_refresh āāāŗ /refresh āāāŗ new access token (day 3)
stolen_refresh āāāŗ /refresh āāāŗ new access token (day 30)
ā²
same token works forever ā and you can't tell
the attacker apart from the real userThe Model: Rotation & Token Families
Two ideas work together. Rotation: every call to /refresh invalidates the token you sent and issues a brand-new one. Families: all refresh tokens from one login share a family_id, so if something goes wrong we can revoke the entire session at once.
Normal rotation (one login = one family):
login āāāŗ RT1 (family A, active)
/refresh RT1 āāāŗ RT1 revoked, RT2 issued (family A)
/refresh RT2 āāāŗ RT2 revoked, RT3 issued (family A)
each refresh token is used exactly ONCE
Reuse detection (RT1 was stolen and replayed):
/refresh RT1 (already revoked!) āāāŗ šØ reuse detected
āāāŗ revoke EVERY token in family A
āāāŗ attacker AND victim are logged out; victim re-logs in| Token | Lifetime | Rotates? | Stored where |
|---|---|---|---|
| Access token (JWT) | ~15 minutes | No ā just expires | Nowhere (stateless) |
| Refresh token | ~7 days | Yes ā every use | DB, hashed, with family_id |
Data Model: Store Refresh Tokens Hashed
Never store the raw refresh token. Hash it (like a password) so a database leak doesn't hand attackers usable tokens. Each row tracks its family and whether it's been used/revoked.
from datetime import datetime
from sqlmodel import Field, SQLModel
class RefreshToken(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
user_id: int = Field(index=True)
family_id: str = Field(index=True) # all tokens from one login
token_hash: str = Field(index=True, unique=True) # sha256 of the raw token
expires_at: datetime
revoked: bool = False # True once rotated or revoked
created_at: datetimeimport hashlib
import secrets
from datetime import datetime, timedelta, timezone
import jwt # PyJWT
from app.settings import settings
ALGORITHM = "HS256"
ACCESS_TTL = timedelta(minutes=15)
REFRESH_TTL = timedelta(days=7)
def now() -> datetime:
return datetime.now(timezone.utc)
def create_access_token(subject: str) -> str:
payload = {"sub": subject, "iat": now(), "exp": now() + ACCESS_TTL}
return jwt.encode(payload, settings.jwt_secret, algorithm=ALGORITHM)
def new_refresh_token() -> str:
return secrets.token_urlsafe(48) # high-entropy, opaque
def hash_token(raw: str) -> str:
# sha256 is fine here: the input is already random, unlike a password
return hashlib.sha256(raw.encode()).hexdigest()sha256 is appropriate ā you don't need bcrypt/argon2 here (those are for low-entropy human passwords). You store the hash; the client holds the only copy of the raw token.Login: Issue Access + Refresh
On login, start a new family and issue the first pair. We'll reuse the token-minting logic for rotation too, so factor it into a helper.
import secrets
from typing import Annotated
from fastapi import Depends, FastAPI, HTTPException, status
from pydantic import BaseModel
from sqlmodel import Session, select
from app.database import get_session
from app.models import RefreshToken
from app.security import (
ACCESS_TTL, REFRESH_TTL, create_access_token, hash_token, new_refresh_token, now,
)
app = FastAPI(title="FastAPI Refresh Rotation", version="0.1.0")
SessionDep = Annotated[Session, Depends(get_session)]
def issue_tokens(session: Session, user_id: int, family_id: str) -> dict:
raw_refresh = new_refresh_token()
session.add(
RefreshToken(
user_id=user_id,
family_id=family_id,
token_hash=hash_token(raw_refresh),
expires_at=now() + REFRESH_TTL,
created_at=now(),
)
)
session.commit()
return {
"access_token": create_access_token(str(user_id)),
"refresh_token": raw_refresh,
"token_type": "bearer",
}
class LoginIn(BaseModel):
email: str
password: str
@app.post("/login")
def login(payload: LoginIn, session: SessionDep):
user_id = authenticate(payload.email, payload.password) # see fastapi-jwt
if user_id is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Bad credentials")
family_id = secrets.token_urlsafe(16) # new login = new family
return issue_tokens(session, user_id, family_id)authenticate() stands in for real credential checking. Use proper password hashing (fastapi-jwt) or social login (fastapi-oauth2-google) in production ā this post focuses on the refresh lifecycle, not the first factor.Refresh: Rotate the Token
The heart of it. Look up the presented refresh token by its hash, validate it, revoke it, and issue a fresh one in the same family. The old token is now dead ā used exactly once.
class RefreshIn(BaseModel):
refresh_token: str
@app.post("/refresh")
def refresh(payload: RefreshIn, session: SessionDep):
row = session.exec(
select(RefreshToken).where(RefreshToken.token_hash == hash_token(payload.refresh_token))
).first()
if row is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token")
if row.revoked:
# a revoked token being reused -> theft. Handled in the next section.
revoke_family(session, row.family_id)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Reuse detected")
if row.expires_at < now():
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Refresh token expired")
# rotate: kill the old one, mint a new one in the SAME family
row.revoked = True
session.add(row)
return issue_tokens(session, row.user_id, row.family_id)Reuse Detection: The Tripwire
Here's the payoff. In normal use, a refresh token is used once and revoked. So if a revoked token shows up again, only one thing explains it: someone kept a copy and replayed it. That's your signal to revoke the entire family ā logging out both the attacker and the victim (who simply logs in again).
def revoke_family(session: Session, family_id: str) -> None:
rows = session.exec(
select(RefreshToken).where(RefreshToken.family_id == family_id)
).all()
for row in rows:
row.revoked = True
session.add(row)
session.commit()That's the revoke_family call already wired into /refresh above: a revoked token being presented triggers a full-family revocation before returning 401.
Logout: Actually Revoke the Session
"Logout" with stateless JWTs is often a lie ā the access token still works until it expires. With server-side refresh tokens you can do it properly: revoke the family so no more access tokens can be minted.
@app.post("/logout")
def logout(payload: RefreshIn, session: SessionDep):
row = session.exec(
select(RefreshToken).where(RefreshToken.token_hash == hash_token(payload.refresh_token))
).first()
if row is not None:
revoke_family(session, row.family_id) # kill the whole session
return {"detail": "Logged out"}Try It (Watch the Tripwire Fire)
fastapi==0.116.1
uvicorn[standard]==0.30.6
sqlmodel==0.0.22
pyjwt==2.9.0
pydantic-settings==2.5.2$ uvicorn app.main:app --reload
$ curl -s -X POST http://127.0.0.1:8000/login \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","password":"secret"}'
{"access_token":"eyJ...","refresh_token":"RT1_abc...","token_type":"bearer"}$ curl -s -X POST http://127.0.0.1:8000/refresh \
-H "Content-Type: application/json" \
-d '{"refresh_token":"RT1_abc..."}'
{"access_token":"eyJ...","refresh_token":"RT2_def...","token_type":"bearer"}
# RT1 is now revoked; RT2 is the live token# attacker replays the stolen RT1 (already used)
$ curl -s -X POST http://127.0.0.1:8000/refresh \
-H "Content-Type: application/json" \
-d '{"refresh_token":"RT1_abc..."}'
{"detail":"Reuse detected"} # 401
# fallout: the whole family is revoked, so even RT2 is now dead
$ curl -s -X POST http://127.0.0.1:8000/refresh \
-H "Content-Type: application/json" \
-d '{"refresh_token":"RT2_def..."}'
{"detail":"Invalid refresh token"} # 401 -> victim must re-loginCommon Mistakes (and Fixes)
| Mistake | Symptom | Fix |
|---|---|---|
| Static, non-rotating refresh token | One leak = permanent access | Rotate on every /refresh |
| Storing raw refresh tokens | DB leak hands out usable tokens | Store sha256 hashes only |
| Revoking only the replayed token | Attacker's newer token survives | Revoke the whole family_id |
| Long-lived access tokens | Logout/revocation barely matters | Keep access TTL short (~15 min) |
| Refresh token in localStorage | XSS can steal it | Use a secure httpOnly cookie (fastapi-cookies) |
| Naive UTC datetime comparisons | Wrong expiry checks across timezones | Use tz-aware UTC + Postgres timestamptz |
Production Notes
- Deliver the refresh token in an httpOnly cookie. For browser apps, don't return it in JSON ā set it as a secure, httpOnly,
SameSitecookie so JavaScript (and XSS) can't read it. Seefastapi-cookies. - Alert on reuse detection. A reuse event is a genuine security signal. Log it with the user and family id (
fastapi-logging) and consider notifying the user "you were signed out for security." - Clean up expired tokens. Periodically delete expired/revoked rows (a cron or background task,
fastapi-celery-redis) so the table doesn't grow forever. - Do the rotation in a transaction. Revoking the old token and inserting the new one should be atomic ā wrap them so you never end up with zero (or two) live tokens (
fastapi-database-transactions).
What's Next
fastapi-jwtā the access-token foundation this builds onfastapi-cookiesā store the refresh token safely in an httpOnly cookiefastapi-oauth2-googleā issue these rotating tokens after a social loginfastapi-rate-limitingā throttle/refreshand/loginagainst abuse
Recap: keep access tokens short and stateless; make refresh tokens single-use and stored hashed with a family_id. Rotate on every refresh, and treat a replayed (already-revoked) token as theft ā revoke the whole family. A stolen refresh token goes from "permanent access" to "one use and an alarm."
fastapi-refresh-rotation/ # ā
finished
āāā requirements.txt
āāā app/
āāā database.py # get_session
āāā models.py # RefreshToken (token_hash, family_id, revoked)
āāā security.py # access JWT + hash_token + new_refresh_token
āāā main.py # /login, /refresh (rotate + reuse detect), /logout