Skip to content

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

  1. Stateless vs DB-backed in 30 seconds — what changes and why.
  2. Setup — the BaseUserRefreshTokenModel table.
  3. Wiring — pass refresh_token_model to the service + router.
  4. How rotation works — families, single-use, reuse.
  5. Logout — the POST /auth/logout endpoint.
  6. Using only UserAuthService — without the router.
  7. Security.
  8. 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:

  1. Marks the presented token used_at (single-use).
  2. Mints a new token in the same family (the rotation lineage of that login).
  3. 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 /refresh is 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 except reads 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 UserAuthService is the only step that turns the mode on: the router detects it and mounts /auth/logout itself.
  • 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

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/refresh kills the family on a theft.
  • POST /auth/logout revokes a session (or all with all_sessions=true); mounted only in DB-backed mode.