Skip to content

Server-side sessions

Since v0.34.0 the SDK ships the full server-side session auth lifecycle — an alternative to the JWT flow from UserAuthService. The cookie carries only an opaque id; real state (user_id, TTL, client metadata, app-level data) lives in a pluggable SessionStore (Memory for dev/tests, Redis for production).

JWT vs server-side sessions

Aspect JWT (UserAuthService) Sessions (SessionAuth)
State stateless (in client) stateful (in Redis/Memory)
Cookie size ~500 B – 1 KB (JWT) 64 B (opaque id)
Revocation wait for token to expire (~1h typical) instant (delete the row)
Global logout needs a blocklist or JWT_SECRET rotation revoke_all(user_id) in one call
CSRF needs custom bearer header HttpOnly cookie + native double-submit token
Multi-device UI ("signed in on 3 devices") no state → impossible without extra work list_sessions(user_id) is trivial
Multi-replica trivial (verify-only) requires Redis (or sticky sessions)
Per-request latency none (CPU decode) 1 Redis hit (~0.5ms LAN)

Use sessions when: B2C SaaS, admin panels, SSR flows (HTMX/Django-like), instant revocation is a requirement, "active devices" UI is a feature.

Use JWT when: public APIs consumed by mobile/SPA, stateless microservices, high scale without a Redis dependency.

Recipe contents

  1. Minimum setup — wire 4 objects (SessionStore, SessionAuth, SessionMiddleware, make_session_router).
  2. Bundled endpoints — login / logout / me / list / revoke.
  3. Settings (SessionSettings) — flags + defaults.
  4. StoresMemorySessionStore vs RedisSessionStore.
  5. How the middleware injects the sessionrequest.state.session + dependency.
  6. Security — anti-fixation rotation, hash-at-rest, anti-enumeration, CSRF.
  7. Trade-offs and when NOT to use — multi-replica, mobile, edge.

Minimum setup

Four objects compose the flow. Mount once in app.py:

# src/api/app.py
from fastapi import FastAPI

from redis.asyncio import Redis

from tempest_fastapi_sdk import (
    AsyncDatabaseManager,
    RedisSessionStore,
    SessionAuth,
    SessionMiddleware,
    SessionSettings,
    make_session_router,
    register_exception_handlers,
)

from src.core.settings import settings
from src.db.models import UserModel

db = AsyncDatabaseManager(settings.DATABASE_URL)
session_settings = SessionSettings()

session_store = RedisSessionStore(
    Redis.from_url(settings.REDIS_URL, decode_responses=True),
    prefix=f"{settings.APP_NAME}:",
)
session_auth = SessionAuth(
    user_model=UserModel,
    store=session_store,
    settings=session_settings,
)


def create_app() -> FastAPI:
    app = FastAPI(title="my-app")
    register_exception_handlers(app)

    # Order matters: middleware BEFORE the routers.
    app.add_middleware(
        SessionMiddleware,
        session_auth=session_auth,
        settings=session_settings,
    )

    app.include_router(
        make_session_router(
            session_auth,
            session_factory=db.session_dependency,
        )
    )
    return app


app = create_app()

Why Redis.from_url here, not AsyncRedisManager?

This client feeds a middleware (SessionMiddleware), built in create_app (sync), before any async lifespan runs. Redis.from_url() is lazy — it constructs without opening a connection, so it fits here. If your service already has an AsyncRedisManager, prefer cache.client_proxy (v0.256.0) over opening a loose client: it is a stable handle, constructible before connect() and valid across a reconnect, and it keeps the manager's disconnect() and health_check(). What does not fit here is cache.client, which raises RuntimeError before the lifespan. All of them need the [cache] extra (the redis package).

Done. The user calls POST /auth/session/login with email+password; the SDK sets the HttpOnly+Secure cookie; every subsequent request that carries the cookie has request.state.session populated.

What each object does

  1. SessionStore (RedisSessionStore / MemorySessionStore) — the persistence layer. Keeps the real session state indexed by the SHA-256 hash of the opaque id. It is the only object that talks to Redis.
  2. SessionAuth — the logic layer. Verifies credentials against the UserModel, mints, rotates, and revokes sessions through the store. Knows nothing about HTTP.
  3. SessionMiddleware — the HTTP → session bridge. On every request it reads the cookie, resolves it through SessionAuth/store, and populates request.state.session before any router runs. Without it, request.state.session never exists and the dependencies raise AttributeError.
  4. make_session_router — exposes the five bundled endpoints (login / logout / me / list / {id}). Takes the same session_auth plus a session_factory to open the DB session on login.

Order matters: add_middleware BEFORE include_router

SessionMiddleware must run on every request to populate request.state.session. Register it with app.add_middleware(...) before mounting the routers via app.include_router(...). Reverse the order and any handler that depends on request.state.session (or on make_session_dependency) finds the attribute missing and breaks. Keep the wiring in exactly the order shown above.


Endpoints

Five bundled endpoints cover the entire lifecycle:

Method Path Body / Output Behavior
POST /auth/session/login SessionLoginSchemaSessionResponseSchema Verifies bcrypt. Mints a new session. Sets Set-Cookie: tempest_session=<id>; HttpOnly; Secure; SameSite=Lax. When a previous cookie exists, rotates it (anti-fixation).
POST /auth/session/logout — → 204 No Content Revokes the current session and clears the cookie. Idempotent.
GET /auth/session/me — → Session Returns the live session (user_id, timestamps, ip, user_agent, data). 401 when no cookie.
GET /auth/session/list — → list[SessionSummarySchema] Lists every live session the user owns ("active devices" UI). Flags the current row with is_current=True.
DELETE /auth/session/{id} — → 204 No Content Revokes one specific session by its public id (first 32 chars of the hash). Clearing the cookie too when the user revokes their own session.

Settings

Mix SessionSettings into your Settings:

from tempest_fastapi_sdk import BaseAppSettings, SessionSettings


class Settings(SessionSettings, BaseAppSettings):
    pass
# .env
SESSION_TTL_SECONDS=86400              # 24h (default)
SESSION_SLIDING=true                   # refresh expires_at on every hit (default)
SESSION_COOKIE_NAME=tempest_session
SESSION_COOKIE_DOMAIN=                 # None = exact host
SESSION_COOKIE_PATH=/
SESSION_COOKIE_SECURE=true             # HTTPS only — set false only for local HTTP dev
SESSION_COOKIE_HTTPONLY=true           # JavaScript cannot read — always true
SESSION_COOKIE_SAMESITE=lax            # lax / strict / none
SESSION_ROTATE_ON_LOGIN=true           # anti-fixation

SESSION_COOKIE_SECURE=false is dev-HTTP only

The default is true: the browser only sends the cookie over HTTPS. Setting false makes the session cookie travel in clear text over HTTP — any network intermediary can capture the id and hijack the session. Use false exclusively in local dev without TLS; never in staging or production. The same holds for keeping SESSION_COOKIE_HTTPONLY=true (default) — turning it off exposes the cookie to XSS.


Stores

MemorySessionStore — dev/tests

from tempest_fastapi_sdk import MemorySessionStore

session_store = MemorySessionStore()

State lives in the process dict. Does not scale — uvicorn restart wipes everything; one replica does not see another's sessions. Use in tests and local dev.

MemorySessionStore does not survive a restart nor scale horizontally

State lives in an in-process dict. Every uvicorn restart/redeploy logs everyone out, and with more than one replica each worker sees only its own sessions (a cookie issued by one replica hits 401 on the other). It is strictly for tests and local dev — in production always use RedisSessionStore.

RedisSessionStore — production

from redis.asyncio import Redis

from tempest_fastapi_sdk import RedisSessionStore

from src.core.settings import settings


session_store = RedisSessionStore(
    Redis.from_url(settings.REDIS_URL, decode_responses=True),
    prefix="myapp:",
)

Internal schema:

  • myapp:sess:<sha256-hex> — JSON of the Session, TTL = expires_at - now
  • myapp:user:<user-uuid> — Redis SET of session hashes (index for list_by_user / delete_by_user)

Redis handles TTL automatically — no janitor process needed.

RedisSessionStore requires the [cache] extra

RedisSessionStore depends on the async redis client, which only ships with the [cache] extra. Since it feeds a middleware, hand it a Redis.from_url(...) (lazy) or AsyncRedisManager.client_proxy — never cache.client, which raises before the lifespan. Install with uv add "tempest-fastapi-sdk[cache]" (add auth etc. as your service needs). MemorySessionStore needs no extra at all.

Custom

Any class that implements the SessionStore protocol (5 async methods) plugs in out of the box — DynamoDB, a Postgres table, Memcached, etc.


Middleware

SessionMiddleware runs before the routers, reads the cookie, resolves through the store, and populates request.state.session:

from fastapi import APIRouter, Depends

from tempest_fastapi_sdk import Session, make_session_dependency

router = APIRouter()


@router.get("/profile")
async def profile(session: Session = Depends(make_session_dependency(required=True))):
    return {"user_id": str(session.user_id), "data": session.data}

required=True (default): no cookie → UnauthorizedException401 in the SDK envelope.

required=False: the handler accepts both — session is Session | None. Use on public endpoints that adapt content for logged-in users.

Direct access (no dependency):

from fastapi import APIRouter, Request

from tempest_fastapi_sdk import Session

router = APIRouter()


@router.get("/anything")
async def handler(request: Request) -> dict:
    s: Session | None = request.state.session
    return {"authenticated": s is not None}

Security

  • Hash at rest: the cookie carries a 32-byte URL-safe plaintext; the store keeps only the SHA-256. A leak of the sessions table does not grant logins.
  • Session-fixation prevention: SESSION_ROTATE_ON_LOGIN=True (default) — a successful login always mints a fresh id, even if the browser already had one. Closes the "attacker plants a known cookie before login" vector.
  • Native CSRF via SameSite: SESSION_COOKIE_SAMESITE=lax (default) blocks cross-site POSTs. Pair with CSRFMiddleware for GET-state-changing endpoints and form submissions.
  • HttpOnly + Secure: SESSION_COOKIE_HTTPONLY=True + SESSION_COOKIE_SECURE=True by default. JavaScript cannot read (anti-XSS); the browser does not send over HTTP.
  • Sliding TTL with floor: SESSION_SLIDING=True (default) refreshes on every hit, but created_at stays put — you can force an absolute logout after N days via a job that prunes rows where created_at < now - 30d.
  • Anti-enumeration: /auth/session/login rejects wrong-email and wrong-password with the same UnauthorizedException and approximately the same timing (bcrypt always runs).
  • Instant revocation: revoke_all(user_id) on password change / suspected compromise → logout on every device on the next request.

Trade-offs

When NOT to use:

  • Public APIs for mobile — native apps care little about cookies; bearer JWT in the Authorization header is still better.
  • Stateless microservices — every replica decodes JWT without a DB hit. Sessions require a shared Redis.
  • Edge/CDN auth — Cloudflare Workers and friends validate JWT at the edge without reaching the origin. Sessions require a backend round-trip.

When to combine JWT + Session:

Possible. A web SPA uses the session cookie; mobile on the same backend uses UserAuthService.login → JWT. Both flows coexist without conflict — UserAuthService and SessionAuth speak to the same UserModel, differing only in the post-verify step (mint JWT vs mint Session).

Recap

  • A server-side session is the alternative to JWT when instant revocation is a requirement: the cookie carries only an opaque id, and the state lives in the store.
  • Four objects make the flow — SessionStore, SessionAuth, SessionMiddleware and make_session_router — mounted once in app.py.
  • Five bundled endpoints cover the whole cycle, and the middleware populates request.state.session before any router runs.
  • The cookie carries the plaintext; the store keeps only the SHA-256. Leaking the sessions table does not log anybody in.
  • MemorySessionStore covers dev and tests; swap the store, not the rest of the wiring, to move to Redis or a database.

Next steps

  • Auth flow » — bundled JWT flow (signup / activate / reset). Sessions only cover login/logout.
  • Security »CSRFMiddleware to harden POSTs against cross-site attacks even with SameSite=lax.
  • Cache »AsyncRedisManager and client_proxy, the right handle for a middleware store like RedisSessionStore.