Skip to content

Session Management

A session is one refresh-token family: every login starts a new family, and refresh rotation keeps that family alive. The sessions router surfaces those families so a user can see where they're signed in and sign out a specific device, without affecting the others.

What you get

Method Route Description
GET /auth/sessions List the user's active sessions. The device making the request is flagged current.
DELETE /auth/sessions/{family_id} Revoke one session. 404 if it isn't the caller's.
POST /auth/sessions/revoke-others Sign out everywhere except the current session.

Each session in the list carries:

{
  "family_id": "9f3c…",
  "ip_address": "203.0.113.7",
  "user_agent": "Mozilla/5.0 …",
  "created_at": "2026-06-11T09:00:00Z",
  "last_used_at": "2026-06-11T11:42:00Z",
  "expires_at": "2026-07-11T09:00:00Z",
  "current": true
}

created_at is when the session began (the first login), last_used_at is the most recent refresh, and current marks the session whose access token made the request.

Enabling it

The router is opt-in like the others, and it mounts automatically for the bundled SQLAlchemy and SQLModel adapters — no extra wiring. It registers under your auth prefix (default /api/v1/auth).

fullauth.init_app(app)  # sessions router included by default

# or opt in explicitly alongside the routers you want
fullauth.init_app(app, include_routers=["auth", "profile", "sessions"])

To leave it off, omit "sessions" from include_routers.

How "current session" works

The access token carries a family_id claim identifying its session. When listing, the server compares each session's family against the caller's token and sets current on the match. revoke-others uses the same claim to decide which session to keep.

Access tokens issued before upgrading don't carry the claim; their current flag resolves on the next login or refresh.

Revocation takes effect immediately

Revoking a session signs that device out at once: its refresh token stops working, and so do the access tokens it already holds, because the session's family_id is blacklisted for the access-token lifetime. The same applies to logout, a password change or reset (which end every session), and refresh-token reuse detection.

This relies on the token blacklist (BLACKLIST_ENABLED, on by default). Run it on Redis when you have more than one worker; an in-memory blacklist only knows about revocations made in the same process.

Pruning old refresh tokens

Rotation writes a row per refresh, so the table only grows. Delete the expired rows periodically from your own scheduler:

removed = await fullauth.adapter.prune_expired_refresh_tokens()

It removes only rows whose expiry has passed. A revoked but unexpired row stays: that row is what a replayed token is matched against, and removing it early would turn a stolen-token replay into an ordinary rejection, leaving the family alive. Custom adapters inherit a no-op, so override it if your storage needs pruning.

Using a custom adapter

The built-in adapters implement session listing out of the box. A custom adapter opts in by inheriting SessionAdapterMixin and implementing three methods:

from fastapi_fullauth.adapters import AbstractUserAdapter, SessionAdapterMixin
from fastapi_fullauth.types import SessionInfo

class MyAdapter(AbstractUserAdapter, SessionAdapterMixin):
    async def list_user_sessions(self, user_id) -> list[SessionInfo]: ...
    async def revoke_user_session(self, user_id, family_id) -> bool: ...
    async def revoke_user_sessions_except(self, user_id, keep_family_id) -> int: ...

If the adapter doesn't implement the mixin, the sessions router is skipped — the same way admin is skipped without role support.

Migration

Recording device and IP adds two nullable columns to the refresh-token table: user_agent and ip_address. Both are nullable, so existing rows stay NULL and the change is additive. See Database Migrations for the workflow.