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.
Installation
The refresh-token models (BaseUserRefreshTokenModel) ship with tempest-fastapi-sdk. UserAuthService and make_auth_router need the [auth] extra — uv add "tempest-fastapi-sdk[auth]" (pulls in bcrypt + PyJWT).
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.core.settings import settings
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.
The revocation is committed, and the request rolled back first (v0.252.0)
The family kill has to survive the 401 that immediately follows it. In a FastAPI request the exception leaves through the session dependency's teardown, so up to v0.251.0 the revocation — which only went through flush() — went away with the rollback: the replay was refused and every descendant kept working.
Now, on reuse detection, the service rolls the request's session back, applies the family UPDATE, and commits. Two consequences for your handler:
- Any write the request had staged before
/refreshis dropped. It would have been dropped anyway — the request is closing with a 401 — and committing it as a side effect of a security decision would be a surprise. - Objects loaded in that session before the error are expired. If your
exceptreads an attribute off an earlier object, reload it.
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
from src.db.models import UserModel
async def issue(
service: UserAuthService, session: AsyncSession, user: UserModel
) -> 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).
Recap¶
- A stateless refresh token is a JWT the server accepts because the signature checks out; it cannot be revoked before it expires. The DB-backed mode trades that for a persisted opaque token.
- The table is abstract (
BaseUserRefreshTokenModel); your service ships the concrete one, as it already does for the account token. - Passing the concrete model to
UserAuthServiceis the only step that turns the mode on: the router detects it and mounts/auth/logoutitself. - Every login opens a family; every refresh rotates inside it. Reusing an already-rotated token is a theft signal, and it revokes the whole family.
- Only the hash reaches the database. The plaintext is returned once, at issue time.
- If you mount your own endpoints, three service methods cover it without the router.
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.