DEV Community

Ugur Aslim
Ugur Aslim

Posted on • Originally published at uguraslim.com

JWT Refresh Token Rotation in FastAPI — The Right Way

Most FastAPI tutorials show you how to generate a JWT. Almost none of them show you what happens when that token gets stolen — and how proper refresh token rotation prevents the damage.

This is the pattern I use in production. It's what ships in CitizenApp. Here's the full implementation.

The Problem With Naive JWT Auth

A typical JWT setup looks like this:

# ⚠️ This is incomplete — don't ship this
@app.post("/login")
def login(credentials: LoginSchema, db: Session = Depends(get_db)):
    user = authenticate_user(db, credentials.username, credentials.password)
    access_token = create_access_token({"sub": user.id})
    refresh_token = create_refresh_token({"sub": user.id})
    return {"access_token": access_token, "refresh_token": refresh_token}
Enter fullscreen mode Exit fullscreen mode

The access token expires in 15 minutes. The refresh token expires in... 30 days? Never?

The silent flaw: if an attacker steals the refresh token (XSS, network intercept, log exposure), they have unlimited access for the entire token lifetime. Your 15-minute access token is now meaningless.

Rotation + Reuse Detection

The fix is refresh token rotation with reuse detection:

  1. Every /refresh call issues a new refresh token and invalidates the old one
  2. If an old refresh token is presented again → all sessions for that user are revoked

Reuse of a rotated-away token is a strong signal that the token was stolen.

# models.py
class RefreshToken(Base):
    __tablename__ = "refresh_tokens"

    id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
    user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False)
    token_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
    family: Mapped[uuid.UUID] = mapped_column(nullable=False)  # rotation chain
    used: Mapped[bool] = mapped_column(default=False)
    expires_at: Mapped[datetime] = mapped_column(nullable=False)
    created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
Enter fullscreen mode Exit fullscreen mode

The family column links a rotation chain. When reuse is detected, we revoke the entire family.

The Rotation Endpoint

# auth/routes.py
@router.post("/refresh")
async def refresh_tokens(
    request: Request,
    payload: RefreshRequest,
    db: AsyncSession = Depends(get_async_db),
):
    token_hash = hash_token(payload.refresh_token)

    # Load the token record
    result = await db.execute(
        select(RefreshToken)
        .where(RefreshToken.token_hash == token_hash)
        .with_for_update()  # pessimistic lock — prevents race conditions
    )
    record = result.scalar_one_or_none()

    if not record:
        raise HTTPException(status_code=401, detail="Invalid refresh token")

    # Reuse detection — token was already rotated
    if record.used:
        # Revoke entire rotation family
        await db.execute(
            update(RefreshToken)
            .where(RefreshToken.family == record.family)
            .values(used=True)
        )
        await db.commit()
        raise HTTPException(
            status_code=401,
            detail="Refresh token reuse detected — all sessions revoked"
        )

    if record.expires_at < datetime.utcnow():
        raise HTTPException(status_code=401, detail="Refresh token expired")

    # Mark old token as used (rotated)
    record.used = True

    # Issue new token in the same family
    new_raw = secrets.token_urlsafe(48)
    new_record = RefreshToken(
        user_id=record.user_id,
        token_hash=hash_token(new_raw),
        family=record.family,
        expires_at=datetime.utcnow() + timedelta(days=30),
    )
    db.add(new_record)
    await db.commit()

    # Issue new access token
    access_token = create_access_token({"sub": str(record.user_id)})

    return {
        "access_token": access_token,
        "refresh_token": new_raw,
    }
Enter fullscreen mode Exit fullscreen mode

Hashing Refresh Tokens at Rest

Never store raw refresh tokens in the database. If your DB is compromised, hashed tokens are useless to an attacker.

import hashlib
import secrets

def hash_token(raw: str) -> str:
    """SHA-256 hash — fast, one-way, collision-resistant."""
    return hashlib.sha256(raw.encode()).hexdigest()

def generate_refresh_token() -> tuple[str, str]:
    """Returns (raw_token, hashed_token)."""
    raw = secrets.token_urlsafe(48)
    return raw, hash_token(raw)
Enter fullscreen mode Exit fullscreen mode

The raw token goes to the client (HTTP-only cookie ideally). The hash goes in the DB.

Storing in HTTP-Only Cookies

XSS can't steal what JavaScript can't read:

@router.post("/login")
async def login(response: Response, ...):
    ...
    raw_refresh, _ = generate_refresh_token()
    response.set_cookie(
        key="refresh_token",
        value=raw_refresh,
        httponly=True,
        secure=True,
        samesite="lax",
        max_age=60 * 60 * 24 * 30,  # 30 days
        path="/auth/refresh",         # scoped — not sent on every request
    )
    return {"access_token": access_token}
Enter fullscreen mode Exit fullscreen mode

Test Coverage

Every auth path needs tests. Here's the rotation test:

# tests/test_auth.py
async def test_refresh_token_rotation(client, user_factory):
    user = await user_factory()
    login_resp = await client.post("/auth/login", json={"username": user.email, "password": "testpass"})
    original_refresh = login_resp.cookies["refresh_token"]

    # First refresh — should succeed
    r1 = await client.post("/auth/refresh", cookies={"refresh_token": original_refresh})
    assert r1.status_code == 200
    new_refresh = r1.cookies["refresh_token"]
    assert new_refresh != original_refresh

    # Reuse old token — should trigger reuse detection
    r2 = await client.post("/auth/refresh", cookies={"refresh_token": original_refresh})
    assert r2.status_code == 401
    assert "reuse detected" in r2.json()["detail"]

    # New token should also be revoked after reuse detection
    r3 = await client.post("/auth/refresh", cookies={"refresh_token": new_refresh})
    assert r3.status_code == 401
Enter fullscreen mode Exit fullscreen mode

Summary

Pattern What it prevents
Token rotation Stolen token reuse after expiry
Reuse detection Attacker using a rotated-away token
Family revocation Entire session chain compromised
Hash at rest DB breach → token exposure
HTTP-only cookie XSS token theft

This combination is what I ship by default on every project. Security isn't a feature you bolt on — it's the foundation you build on.


Questions or found an edge case I missed? Reach me at uaslim@me.com

Top comments (0)