FastAPI cookbook · Advanced

Use refresh tokens in HttpOnly cookies with FastAPI

Keep users signed in for weeks while access tokens stay short-lived and safe from XSS.

Approach

Issue a short-lived access token (returned in the JSON body, kept in memory by the SPA) and a long-lived, random refresh token stored hashed in the database and sent as an HttpOnly; Secure; SameSite=Strict cookie scoped to the refresh path. Rotate the refresh token on every use and revoke the whole family if an old one is reused.

Solution

import hashlib, secrets
from datetime import timedelta
from fastapi import Cookie, HTTPException, Response

REFRESH_PATH = "/auth/refresh"


def set_refresh_cookie(response: Response, token: str) -> None:
    response.set_cookie("refresh_token", token, httponly=True, secure=True, samesite="strict",
                        path=REFRESH_PATH, max_age=int(timedelta(days=30).total_seconds()))


async def issue_refresh(db, user_id: int, family: str | None = None) -> str:
    raw = secrets.token_urlsafe(48)
    db.add(RefreshToken(user_id=user_id, family=family or secrets.token_hex(8),
                        token_hash=hashlib.sha256(raw.encode()).hexdigest(), expires_at=utcnow() + timedelta(days=30)))
    return raw


@router.post("/auth/refresh")
async def refresh(response: Response, db: DB, refresh_token: str | None = Cookie(default=None)):
    if not refresh_token:
        raise HTTPException(401, "No refresh token")
    row = await db.scalar(select(RefreshToken).where(RefreshToken.token_hash == hashlib.sha256(refresh_token.encode()).hexdigest()))
    if row is None or row.expires_at < utcnow():
        raise HTTPException(401, "Invalid refresh token")
    if row.used_at is not None:                                  # reuse → likely theft
        await db.execute(update(RefreshToken).where(RefreshToken.family == row.family).values(revoked=True))
        await db.commit()
        raise HTTPException(401, "Refresh token reuse detected")
    if row.revoked:
        raise HTTPException(401, "Session revoked")
    row.used_at = utcnow()
    new_raw = await issue_refresh(db, row.user_id, family=row.family)
    await db.commit()
    set_refresh_cookie(response, new_raw)
    return {"access_token": create_access_token(row.user_id), "token_type": "bearer"}

Pitfalls

  • Refresh tokens in localStorage.
  • Not rotating refresh tokens.
  • Cross-site setups where SameSite=Strict blocks the cookie (adjust domain/SameSite and add CSRF protection).

Variations

  • A BFF (backend-for-frontend) keeps all tokens server-side and uses a session cookie only.

Further reading: RFC 9700: OAuth 2.0 Security Best Current Practice

Reference: https://datatracker.ietf.org/doc/html/rfc9700

More FastAPI recipes