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¶
- Minimum setup — wire 4 objects (
SessionStore,SessionAuth,SessionMiddleware,make_session_router). - Bundled endpoints — login / logout / me / list / revoke.
- Settings (
SessionSettings) — flags + defaults. - Stores —
MemorySessionStorevsRedisSessionStore. - How the middleware injects the session —
request.state.session+ dependency. - Security — anti-fixation rotation, hash-at-rest, anti-enumeration, CSRF.
- 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¶
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.SessionAuth— the logic layer. Verifies credentials against theUserModel, mints, rotates, and revokes sessions through thestore. Knows nothing about HTTP.SessionMiddleware— the HTTP → session bridge. On every request it reads the cookie, resolves it throughSessionAuth/store, and populatesrequest.state.sessionbefore any router runs. Without it,request.state.sessionnever exists and the dependencies raiseAttributeError.make_session_router— exposes the five bundled endpoints (login/logout/me/list/{id}). Takes the samesession_authplus asession_factoryto 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 |
SessionLoginSchema → SessionResponseSchema |
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¶
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 theSession, TTL =expires_at - nowmyapp:user:<user-uuid>— Redis SET of session hashes (index forlist_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 → UnauthorizedException → 401 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
sessionstable 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 withCSRFMiddlewarefor GET-state-changing endpoints and form submissions. - HttpOnly + Secure:
SESSION_COOKIE_HTTPONLY=True+SESSION_COOKIE_SECURE=Trueby 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, butcreated_atstays put — you can force an absolute logout after N days via a job that prunes rows wherecreated_at < now - 30d. - Anti-enumeration:
/auth/session/loginrejects wrong-email and wrong-password with the sameUnauthorizedExceptionand 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
Authorizationheader 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,SessionMiddlewareandmake_session_router— mounted once inapp.py. - Five bundled endpoints cover the whole cycle, and the middleware populates
request.state.sessionbefore any router runs. - The cookie carries the plaintext; the store keeps only the SHA-256. Leaking the sessions table does not log anybody in.
MemorySessionStorecovers 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 » —
CSRFMiddlewareto harden POSTs against cross-site attacks even withSameSite=lax. - Cache » —
AsyncRedisManagerandclient_proxy, the right handle for a middleware store likeRedisSessionStore.