DB-backed refresh tokens (rotation + revocation)¶
Since v0.66.0 the bundled auth flow can swap the stateless JWT refresh token for an opaque token persisted in the database, gaining three things a plain JWT never gives you: real rotation, reuse detection (stolen token), and revocation (logout that kills the session before its expiry).
It is all opt-in: you pass a refresh_token_model to the service. Without it, the SDK keeps the stateless behavior it always had — zero breaking change.
What's in this recipe¶
- Stateless vs DB-backed in 30 seconds — what changes and why.
- Setup — the
BaseUserRefreshTokenModeltable. - Wiring — pass
refresh_token_modelto the service + router. - How rotation works — families, single-use, reuse.
- Logout — the
POST /auth/logoutendpoint. - Using only
UserAuthService— without the router. - Security.
- Next steps.
Stateless vs DB-backed¶
A stateless refresh token is just a JWT signed with the refresh claim. The server trusts it if the signature matches and it has not expired — there is no database row. Simple, but:
- You cannot revoke it (logout kills nothing — the token lives until
exp). - There is no real rotation: you mint a new pair, but the old one stays valid in parallel.
- There is no reuse detection: a stolen token works for days and nobody notices.
A DB-backed refresh token is an opaque value (random, no claims) whose SHA-256 hash lives in a table. Every POST /auth/refresh:
- Marks the presented token
used_at(single-use). - Mints a new token in the same family (the rotation lineage of that login).
- If anyone replays an already-rotated token, that is the classic stolen-token signal → the whole family is revoked.
Why opaque and not JWT-with-jti?
An opaque token forces the database to be the single source of truth. There are no claims to decode, no window between "signature valid" and "row revoked". The access token stays a stateless JWT (short, no per-request lookup) — only the refresh becomes DB-backed.
Setup¶
The table is abstract in the SDK (BaseUserRefreshTokenModel) — your application ships the concrete table, just like BaseUserTokenModel / BaseUserRecoveryCodeModel. Use the make_user_refresh_token_model helper to bind the FK to your users table:
# src/db/models/__init__.py
from tempest_fastapi_sdk import (
make_user_refresh_token_model,
make_user_token_model,
)
from src.db.models.user import UserModel
UserRefreshTokenModel = make_user_refresh_token_model(
user_table="users",
tablename="user_refresh_tokens",
class_name="UserRefreshTokenModel",
)
Or, if you prefer a hand-written class (recommended in production, for refactors and stable imports):
# src/db/models/user_refresh_token.py
from sqlalchemy import ForeignKey
from sqlalchemy.orm import Mapped, mapped_column
from uuid import UUID
from tempest_fastapi_sdk import BaseUserRefreshTokenModel
class UserRefreshTokenModel(BaseUserRefreshTokenModel):
"""Concrete opaque refresh-token table."""
__tablename__ = "user_refresh_tokens"
user_id: Mapped[UUID] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
The table carries: token_hash (unique, indexed), family_id (rotation lineage), expires_at, used_at (rotated), and revoked_at (logout / family kill).
Migration required
It is a new table. Run uv run tempest db revision -m "refresh tokens" + uv run tempest db upgrade before shipping.
Wiring¶
Pass the concrete model to UserAuthService. That alone turns on DB-backed mode — the router detects it and mounts /auth/logout on its own:
# src/api/dependencies/services.py
from tempest_fastapi_sdk import UserAuthService
from src.db.models import UserModel, UserRefreshTokenModel, UserTokenModel
def get_auth_service() -> UserAuthService:
"""Build the bundled auth service in DB-backed refresh mode."""
return UserAuthService(
user_model=UserModel,
token_model=UserTokenModel,
auth_settings=settings,
jwt_settings=settings,
refresh_token_model=UserRefreshTokenModel, # <- turns on DB-backed mode
)
The refresh-token TTL reuses JWT_REFRESH_TTL_SECONDS (default 7 days) — no new setting.
Migrating from stateless
Adopting DB-backed mode does not invalidate existing sessions up front, but old JWT refresh tokens stop being accepted (/refresh now looks them up in the database). Force a fresh login after the deploy, or run a grace period accepting both in your own handler.
Rotation¶
Every login (or auto-activated signup / activation / reset / mfa-verify) creates a token in a fresh family. Every refresh rotates within the same family:
login ──> tokenA (family F)
│
└─ POST /refresh (tokenA) ──> tokenA.used_at set, mint tokenB (family F)
│
└─ POST /refresh (tokenB) ──> tokenB.used_at set, mint tokenC (family F)
If an attacker steals tokenA and tries to use it after you have already rotated to tokenB:
POST /refresh (tokenA) # tokenA.used_at != null -> REUSE DETECTED
└─ revoke the WHOLE family F (tokenA, tokenB, tokenC...)
└─ 401
Result: both the attacker and the victim are logged out on their next attempt. The victim logs in again (a small annoyance), the attacker loses access (a big win).
Single-use is mandatory for reuse detection to work
The client must discard the old refresh token after each /refresh and keep the new one. Reusing a rotated token triggers the family kill — that is not a bug, it is the feature.
Logout¶
With DB-backed mode on, the router mounts POST /auth/logout:
import httpx
async def logout(refresh_token: str) -> None:
"""Revoke a session via the bundled logout endpoint."""
async with httpx.AsyncClient(base_url="http://localhost:8000") as client:
response = await client.post(
"/auth/logout",
json={"refresh_token": refresh_token, "all_sessions": False},
)
response.raise_for_status() # 204 No Content
all_sessions=False(default) — revoke only the token's family (that login).all_sessions=True— revoke all of the user's refresh tokens (log out everywhere).
The endpoint is idempotent: an unknown or already-revoked token still returns 204 and never leaks whether the token existed.
Absent in stateless mode
Without refresh_token_model the /auth/logout route is not mounted — a stateless JWT cannot be revoked, so the endpoint would make no sense.
Service direct¶
Those who build their own endpoints use the service without the router. The three methods:
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from tempest_fastapi_sdk import UserAuthService
async def issue(service: UserAuthService, session: AsyncSession, user: object) -> None:
"""Emit a DB-backed pair, rotate it, then revoke the session."""
access, refresh = await service.issue_token_pair(session, user)
await session.commit()
# Rotate: mark the old token used, mint a new one in the same family.
_user, new_access, new_refresh = await service.refresh_tokens(
session, refresh_token=refresh
)
await session.commit()
# Logout: revoke the family (or all_sessions=True for everything).
await service.revoke_refresh_token(session, refresh_token=new_refresh)
await session.commit()
| Method | What it does |
|---|---|
issue_token_pair(session, user, *, family_id=None) |
Emit (access, refresh). Opaque+persisted when a model is wired; stateless JWT otherwise. |
refresh_tokens(session, *, refresh_token) |
Rotate. Detects reuse → revokes the family. Returns (user, access, refresh). |
revoke_refresh_token(session, *, refresh_token, all_sessions=False) |
Logout. Revokes the family (or everything). Idempotent. |
issue_jwt_pair still exists
The synchronous issue_jwt_pair(user) (pure stateless) stays available for back-compat. In DB-backed mode prefer issue_token_pair, which takes the session and persists the row.
Security¶
- Only the hash in the database. The refresh token plaintext is returned once at issuance; the database stores only the SHA-256. A database leak yields no usable tokens.
- Single-use + family. Mandatory rotation + family kill turn a refresh-token theft from "access for days" into "one attempt and both go down".
- Access token unchanged. Still a short stateless JWT (
JWT_ACCESS_TTL_SECONDS, default 1h) — no per-request lookup. DB-backed is for the refresh only. - CASCADE. The FK with
ondelete="CASCADE"wipes the tokens alongside the user.
Change the JWT_SECRET
The access token is still signed with JWT_SECRET. The default "change-me-change-me-change-me-32" is a placeholder — override it in production, otherwise the access token is forgeable (and then the DB-backed refresh does not save you).
Next steps¶
- Auth flow (signup/reset) — the full flow where tokens are issued.
- MFA (TOTP / 2FA) — second factor;
mfa-verifyalso emits the DB-backed pair. - Security — rate limit, idempotency, CSRF middlewares.
Recap¶
refresh_token_model=turns on DB-backed mode — without it, stateless as always.- Refresh becomes opaque (hash in the database); access stays a JWT.
- Single-use rotation + family = reuse detection →
POST /auth/refreshkills the family on a theft. POST /auth/logoutrevokes a session (or all withall_sessions=true); mounted only in DB-backed mode.