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. - Login without a user table — fixed credential, HTML route with a redirect, no middleware.
- 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)
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,make_session_dependency()finds no session and answers401even with a valid cookie — unless you passsession_auth=to the dependency, which then resolves the cookie itself (no middleware).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.
The order between add_middleware and include_router does not matter
Starlette builds the middleware stack on the first request, around the whole app, so SessionMiddleware also wraps routers included before it. Measured here (Starlette 1.6.0, FastAPI 0.141.1): with include_router before add_middleware, login answers 200 and the following GET /auth/session/me answers 200 too, same as the order in the example. What matters is registering the middleware before the app starts serving: add_middleware after the first request raises RuntimeError: Cannot add middleware after an application has started. Wire everything inside create_app.
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_SAMESITE is Literal["lax", "strict", "none"] (alias SessionCookieSameSite). Any other value — Strict, LAX, lax; — fails Settings() construction with literal_error, at boot rather than on the first response. Surrounding whitespace (lax) is trimmed, as before.
To write and delete the cookie, use the two mappers instead of repeating seven settings in set_cookie and three in delete_cookie:
from fastapi import Response
from tempest_fastapi_sdk import BaseAppSettings, SessionSettings
class Settings(SessionSettings, BaseAppSettings):
pass
settings = Settings()
def start_session(response: Response, plaintext: str) -> None:
response.set_cookie(value=plaintext, **settings.session_cookie_kwargs())
def end_session(response: Response) -> None:
response.delete_cookie(**settings.session_cookie_delete_kwargs())
session_cookie_kwargs() carries the cookie name as key, so the call site passes only the id. session_cookie_delete_kwargs() repeats path, domain, secure, httponly and samesite from the same fields — a browser only drops the cookie when the deleting Set-Cookie matches the path and domain it was set with. Both return a TypedDict (SessionCookieKwargs / SessionCookieDeleteKwargs), so mypy checks the splat against Starlette's signature. make_session_router uses the same mappers.
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}
Login without a user table¶
SessionAuth(user_model=...) looks the e-mail up in a table and verifies the hash. An admin panel guarded by one root credential from the environment has no table to point at. For that case, SessionAuth.from_credentials(username, password, ...) builds the service on a StaticCredentialAuthenticator, and make_session_dependency(session_auth=..., on_missing=redirect_to("/login")) guards the HTML route with no middleware at all.
The whole example — login form, protected page and logout:
from fastapi import Depends, FastAPI, Form, Request, status
from fastapi.responses import HTMLResponse, RedirectResponse, Response
from pydantic import SecretStr
from tempest_fastapi_sdk import (
BaseAppSettings,
MemorySessionStore,
Session,
SessionAuth,
SessionSettings,
UnauthorizedException,
make_session_dependency,
redirect_to,
register_exception_handlers,
)
class Settings(SessionSettings, BaseAppSettings):
ADMIN_USERNAME: str
ADMIN_PASSWORD: SecretStr
settings = Settings()
session_auth = SessionAuth.from_credentials(
settings.ADMIN_USERNAME,
settings.ADMIN_PASSWORD,
store=MemorySessionStore(),
settings=settings,
)
require_admin = make_session_dependency(
session_auth=session_auth,
on_missing=redirect_to("/login"),
)
app = FastAPI()
register_exception_handlers(app)
@app.get("/login", response_class=HTMLResponse)
async def login_page() -> str:
return (
'<form method="post" action="/login">'
'<input name="username"> <input name="password" type="password">'
"<button>Entrar</button></form>"
)
@app.post("/login")
async def login(
request: Request,
username: str = Form(),
password: str = Form(),
) -> Response:
try:
_session, plaintext = await session_auth.login_with_credentials(
username,
password,
previous_session_id=request.cookies.get(settings.SESSION_COOKIE_NAME),
)
except UnauthorizedException:
return HTMLResponse("Credenciais inválidas", status_code=401)
response = RedirectResponse("/admin", status_code=status.HTTP_303_SEE_OTHER)
response.set_cookie(value=plaintext, **settings.session_cookie_kwargs())
return response
@app.get("/admin", response_class=HTMLResponse)
async def admin(session: Session = Depends(require_admin)) -> str:
return f"<h1>Painel</h1><p>Sessão expira em {session.expires_at:%H:%M}</p>"
@app.post("/logout")
async def logout(request: Request) -> Response:
cookie = request.cookies.get(settings.SESSION_COOKIE_NAME)
if cookie:
await session_auth.revoke(cookie)
response = RedirectResponse("/login", status_code=status.HTTP_303_SEE_OTHER)
response.delete_cookie(**settings.session_cookie_delete_kwargs())
return response
With ADMIN_USERNAME=root, ADMIN_PASSWORD=s3cret and SESSION_COOKIE_SECURE=false in the environment, driven by httpx.AsyncClient:
GET /admin sem cookie: 303 /login
POST /login errado: 401 Credenciais inválidas
POST /login certo: 303 /admin
Set-Cookie: tempest_session=63XC...; HttpOnly; Max-Age=86400; Path=/; SameSite=lax
GET /admin com cookie: 200 <h1>Painel</h1><p>Sessão expira em 16:55</p>
POST /logout: 303 /login
Set-Cookie: tempest_session=""; expires=...; HttpOnly; Max-Age=0; Path=/; SameSite=lax
GET /admin com cookie revogado: 303 /login
(The labels are the Portuguese ones the run printed: no cookie, wrong login, right login, with cookie, logout, revoked cookie.)
Piece by piece¶
SessionAuth.from_credentials(...)— accepts exactly one pair. Both halves are always compared, each withhmac.compare_digestover the value's SHA-256, and the results are combined with&, notand: a wrong username costs the same as a wrong password, and the message (invalid username or password) is the same for both. It takes aSecretStr, so theSettingsfield goes straight in. An empty credential raisesValueErrorat construction — an empty env var does not turn into "accept an empty form". It does not need the[auth]extra: the comparison is stdlib only, and thePasswordUtils(bcrypt) theuser_model=mode uses is never built here.- The session owner is
uuid5(STATIC_CREDENTIAL_NAMESPACE, username)by default: stable across restarts and replicas, sosession_auth.revoke_all(...)reaches every root session. Passuser_id=to pick another. login_with_credentials(...)— verifies and opens the session in one call. Wrong credentials raiseUnauthorizedExceptionwithout creating a session and without revoking the previous one; the route decides what to render.make_session_dependency(session_auth=..., on_missing=redirect_to("/login"))— reads the cookie and resolves the session itself (with the sliding TTL, like the middleware). With no session it raisesHTTPException(303, Location=/login); FastAPI's handler and the SDK'sregister_exception_handlersboth keep the header, and the browser follows. Withouton_missing, the answer stays401.- Logout —
revoke(cookie)deletes the session from the store (the same cookie then redirects to the login) anddelete_cookie(**settings.session_cookie_delete_kwargs())drops the cookie in the browser.
Another credential backend
from_credentials is a shortcut. Any object with async def authenticate(self, username: str, password: str, /) -> UUID that raises UnauthorizedException on refusal satisfies the SessionAuthenticator protocol — LDAP, an upstream API — and goes into SessionAuth(authenticator=..., store=..., settings=...). Pass user_model= or authenticator=, never both. make_session_router (JSON login by e-mail) needs user_model= and refuses, at build time, a service built with authenticator=.
SessionMiddleware is a BaseHTTPMiddleware and sits in the path of every response
The middleware wraps the whole app, and Starlette's BaseHTTPMiddleware relays every chunk of the response body through an in-memory stream. Measured here (Starlette 1.6.0, direct ASGI call with an empty send, median of 7 runs): a 256 MiB StreamingResponse in 64 KiB chunks took ~4 ms with no middleware and ~64 ms with SessionMiddleware — about 15 µs per chunk, paid by every route, including the upload/download that never reads the session. In a service that streams files and uses a session on three routes, prefer the dependency with session_auth=: only the routes that declare it pay for the lookup. If the middleware is mounted anyway, the dependency reuses the session it resolved instead of resolving again.
SessionAuth built after import¶
In the example above the SessionAuth exists at import, because Settings() runs at module level. In a service whose settings sit behind an @lru_cache — which the tests clear and rebuild per test — there is no SessionAuth to pass when the dependency is declared. Pass a factory that takes the request:
from functools import lru_cache
from typing import Annotated
from fastapi import Depends, FastAPI, Request
from fastapi.responses import HTMLResponse
from pydantic import SecretStr
from tempest_fastapi_sdk import (
BaseAppSettings,
MemorySessionStore,
Session,
SessionAuth,
SessionSettings,
make_session_dependency,
redirect_to,
register_exception_handlers,
)
class Settings(SessionSettings, BaseAppSettings):
ADMIN_USERNAME: str
ADMIN_PASSWORD: SecretStr
def get_session_auth(request: Request) -> SessionAuth:
auth: SessionAuth = request.app.state.session_auth
return auth
AdminSession = Annotated[
Session,
Depends(
make_session_dependency(
session_auth=get_session_auth,
on_missing=redirect_to("/login"),
)
),
]
@lru_cache
def get_settings() -> Settings:
return Settings()
def create_app() -> FastAPI:
settings = get_settings()
app = FastAPI()
register_exception_handlers(app)
app.state.session_auth = SessionAuth.from_credentials(
settings.ADMIN_USERNAME,
settings.ADMIN_PASSWORD,
store=MemorySessionStore(),
settings=settings,
)
@app.get("/admin", response_class=HTMLResponse)
async def admin(session: AdminSession) -> str:
return f"<h1>Panel</h1><p>Session expires at {session.expires_at:%H:%M}</p>"
return app
get_session_auth(request)— returns the service of the app serving the request.make_session_dependencycalls it per request, and only when the middleware did not resolve the session first; declaring the dependency never calls it. Two apps in one process — a test that runsget_settings.cache_clear()and builds another — each resolve against their own: one app's cookie does not open the other's panel.AdminSession— the alias lives at module level, before anySessionAuthexists, and routes use it as is.- The type is
Session, notSession | None. Withrequired=True(the default) the dependency raises instead of returningNone, and the signature says so: noif session is Nonejust for the type checker. Withrequired=Falsethe type staysSession | 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, in the body and in the timing:
/auth/session/loginrejects an unknown e-mail, a wrong password and an inactive account with the sameUnauthorizedExceptionand the same message (invalid email or password). And all three refusals pay one bcrypt verification: an unknown e-mail is checked against a throwaway hash (PasswordUtils.dummy_verify), and an inactive account's password is verified beforeis_active. Measured on this machine (bcrypt cost 12,SessionAuth.authenticatecalled directly, median of N=21): wrong password 153.9 ms, unknown e-mail 154.1 ms — before the fix, the unknown e-mail answered in 0.4 ms. What is not bcrypt still varies (the query, the network), and the process's first refusal for an unknown e-mail pays one extra hash to build the throwaway hash. Rate-limit attempts per IP/identifier anyway: that closes brute force, which equal timing does not. - 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.
- Without a user table,
SessionAuth.from_credentials(...)compares both halves of the credential in constant time, andmake_session_dependency(session_auth=..., on_missing=redirect_to("/login"))guards an HTML route with a303, no middleware. With theSessionAuthbuilt after import,session_auth=takes a(request) -> SessionAuthfactory, and the dependency alias comes out typed asSession. settings.session_cookie_kwargs()/session_cookie_delete_kwargs()write and delete the cookie with the same attributes.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.