FastAPI

FastAPI Refresh Token Rotation (Secure Sessions with Reuse Detection)

Thirdy Gayares
14 min read

šŸŽÆ 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 /refresh rotation endpoint
  • Reuse detection: revoke the family when a used token reappears
  • /logout that 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, /logout

The 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/main.py (the naive refresh)
@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... indefinitely
Attacker 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 user
āš ļø
The core weakness: a static refresh token is a bearer credential that never changes and lives for weeks. One leak = long-term account access, with no signal that anything is wrong. Rotation makes each refresh token single-use and gives you a tripwire.

The 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
TokenLifetimeRotates?Stored where
Access token (JWT)~15 minutesNo — just expiresNowhere (stateless)
Refresh token~7 daysYes — every useDB, hashed, with family_id
šŸ’”
Ang importante dito: the access token stays stateless and short-lived (fast checks, low blast radius). Only the refresh token is stored server-side — because rotation and revocation need state you can look up and invalidate.

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.

app/models.py
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: datetime
app/security.py
import 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()
šŸ’”
Refresh tokens are already high-entropy random strings, so a fast hash like 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.

app/main.py
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)
āš ļø
Learning shortcut: 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.

app/main.py
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)
āœ…
Single-use tokens: after this call the client must use the new refresh token; the old one is revoked forever. That's what makes a leaked token useful for at most one rotation — and sets up the tripwire.

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).

app/main.py
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.

šŸ’”
Why revoke the whole family, not just the token? When a replay happens you can't tell which holder is the attacker — the real user or the thief. The only safe move is to invalidate every token in that login session and force a fresh, clean login. One stolen token can no longer quietly ride alongside the legit one.

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/main.py
@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"}
āš ļø
Access tokens still live until they expire. Logout revokes refreshing, but the current access token works for up to its TTL. Keep that TTL short (15 min) so the window is small — or maintain a small deny-list for immediate access-token revocation if your threat model needs it.

Try It (Watch the Tripwire Fire)

requirements.txt
fastapi==0.116.1
uvicorn[standard]==0.30.6
sqlmodel==0.0.22
pyjwt==2.9.0
pydantic-settings==2.5.2
1Run + log in
$ 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"}
2Rotate normally (RT1 → RT2)
$ 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
3Replay the old RT1 → tripwire fires
# 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-login
āœ…
You did it: normal rotation works, but the instant a used token is replayed the whole session is torched. A stolen refresh token is now worth at most one rotation — and it trips an alarm you can log and alert on.

Common Mistakes (and Fixes)

MistakeSymptomFix
Static, non-rotating refresh tokenOne leak = permanent accessRotate on every /refresh
Storing raw refresh tokensDB leak hands out usable tokensStore sha256 hashes only
Revoking only the replayed tokenAttacker's newer token survivesRevoke the whole family_id
Long-lived access tokensLogout/revocation barely mattersKeep access TTL short (~15 min)
Refresh token in localStorageXSS can steal itUse a secure httpOnly cookie (fastapi-cookies)
Naive UTC datetime comparisonsWrong expiry checks across timezonesUse 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, SameSite cookie so JavaScript (and XSS) can't read it. See fastapi-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

āœ…
šŸš€ Recommended next reads:
  • fastapi-jwt — the access-token foundation this builds on
  • fastapi-cookies — store the refresh token safely in an httpOnly cookie
  • fastapi-oauth2-google — issue these rotating tokens after a social login
  • fastapi-rate-limiting — throttle /refresh and /login against 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

About the Author

TG

Thirdy Gayares

Passionate developer creating custom solutions for everyone. I specialize in building user-friendly tools that solve real-world problems while maintaining the highest standards of security and privacy.