Sessions, CSRF and security headers¶
What an app that browsers use needs: sessions it can revoke, requests from other sites refused, and the headers and limits every response should carry.
In an app: the example. Every class and function: the API reference.
Server-side sessions¶
Starlette's SessionMiddleware keeps the whole session in a signed cookie: the client can read
it (it's only base64), it can't be revoked (a copied cookie keeps working after logout, until it
expires) and it can't hold much more than 4 KB. ServerSessionMiddleware keeps the data in the
cache's backend instead, and the cookie only carries a random id; request.session works the
same:
from fastapi_alcazaba.security import ServerSessionMiddleware, clear_session, rotate_session
app.add_middleware(ServerSessionMiddleware, max_age=8 * 3600, idle_timeout=1800)
@app.post("/login")
async def login(request: Request, form: Annotated[Login, Form()]) -> RedirectResponse:
user = await authenticate(form)
request.session["user_id"] = user.id
rotate_session(request) # a new id: one planted in the browser before is now worthless
return RedirectResponse("/", status_code=303)
@app.post("/logout")
async def logout(request: Request) -> RedirectResponse:
clear_session(request) # deleted from the backend: a copied cookie is worth nothing now
return RedirectResponse("/", status_code=303)
- The cookie holds 256 random bits (
secrets.token_urlsafe(32)),HttpOnly,SecureandSameSite=Laxby default, withMax-Age=max_age, named__Host-sessionso that a sibling subdomain can't plant one (cookie tossing). Plain HTTP in development needssecure=Falseand anothercookie_name. - The backend keeps the data under a SHA-256 digest of the id (whoever reads the keys can't use
them as cookies), sealed with the cache's
signing_keyorencryption_key(use the latter when sessions hold personal data). Without either, whoever can write to the backend can forge a session, and a warning is logged. With aTieredBackendonly the shared tier is used, so a logout is seen by every process at once. - Saved only when it changed, nested changes included (
request.session["cart"].append(...)), when the response starts. Empty sessions aren't stored and get no cookie; a session the app empties is deleted and its cookie expired. Responses that read it getVary: Cookie, and those that set the cookieCache-Control: no-storeunless they set their own. - New ids are always the server's: a cookie whose session doesn't exist (expired, made up)
never becomes the id of a new session.
rotate_sessiongives a new id with the same data (and a freshmax_age) at login, against session fixation;clear_sessiondeletes the session and expires the cookie at logout, and data stored afterwards goes to a new session. - Expiry:
max_ageseconds after creation or rotation, andidle_timeoutseconds without requests (refreshed in the backend at most every tenth of it, not on every request). Both are checked when the session is read, and the backend drops it then as well. - JSON only, up to
max_sizebytes (16 KiB): more, or a value that isn't JSON, raises when the response starts. - Backend failures: if the session can't be read, the request goes on with an empty one (anonymous, which fails closed) and a warning. Changing it then, or failing to save, rotate or clear it, replaces the response with a 503 and leaves the cookie alone: the response would claim a login or a logout that didn't happen. Concurrent requests of one session: the last save wins.
- WebSockets get
websocket.sessiontoo, read-only (rotate_sessionandclear_sessionraise there). A plain ASGI middleware: streaming responses still stream.
Each user's sessions¶
Experimental
UserSessions is new in 0.6: its API may still change in any release.
from fastapi_alcazaba.security import ServerSessionMiddleware, UserSessions
user_sessions = UserSessions() # the sessions whose data has "user_id", as SessionStrategy's
app.add_middleware(ServerSessionMiddleware, users=user_sessions)
@app.get("/devices")
async def devices(request: Request, user: CurrentUser) -> list[SessionInfo]:
return await user_sessions.list(user.id, request=request)
@app.post("/password")
async def change_password(request: Request, form: NewPassword, user: CurrentUser) -> None:
await db.users.set_password(user.id, form.password)
await user_sessions.end_all(user.id, request=request, keep_current=True)
- Listed:
list(user_id)gives the user's live sessions, the newest first: when each started and was last seen, the browser'sUser-Agentand the client's address when it started (record_client=Falsefor neither), and which is the request's (current). Itsrefnames it toend(user_id, ref), which only ends the user's own; it isn't the session's id, which only its cookie holds. - Ending all of them ("log out everywhere", a new password, a stolen phone):
end_all(user_id)ends every session in the index, and refuses, from then on, every session of the user that started before, those the index missed too.keep_current=Truekeeps the request's, with a new id. It costs one more read for each request with a user. - Kept with the sessions: the middleware keeps an index of each user's sessions in the
backend, sealed like them, when they start, rotate and end, holding a lock (where the backend
has locks) so that two logins at once don't leave one out. At most
max_sessions(50): a login past it ends the oldest. - When the backend fails to tell whether a session was ended, the session is refused: fail closed.
CSRF protection¶
A page on another site can make the user's browser send a form, or a fetch, to your app, and
the browser adds the user's cookies by itself. CSRFMiddleware refuses those requests with a 403,
from the headers browsers send to say where a request comes from:
from fastapi_alcazaba.security import CSRFMiddleware
app.add_middleware(
CSRFMiddleware,
trusted_origins=["https://admin.example.com"], # other origins whose pages may post here
exempt_paths=["/webhooks"], # signed in their own way
)
- Fetch metadata first: every current browser sends
Sec-Fetch-Site.same-origin(andnone, a request the user typed) goes through;same-siteandcross-siteare refused unless theirOriginis intrusted_origins. Sibling subdomains are other origins too. - Older browsers: without
Sec-Fetch-Site, theOriginheader (or else theReferer) must be the app's own origin or a trusted one.Origin: null(sandboxed frames, redirects) is refused, and so is a request with neither header. - Only what the browser adds by itself is protected: requests without cookies or remembered
HTTP authentication (Basic, Digest, Negotiate, NTLM) go through, so APIs that take a bearer
token in a header keep working from anywhere (a page on another site can't set the header).
Except form submissions from other sites (
Sec-Fetch-Modeother thancors): before the user has a cookie they would log the browser into the attacker's account (login CSRF).check_all=Truechecks every request, for apps that trust TLS client certificates or the network. - Safe methods (GET, HEAD, OPTIONS, TRACE) always go through: they mustn't change anything.
- The app's own origin is the scheme and the
Hostheader as the server sees them. Behind a proxy that terminates TLS, let the server trust it (uvicorn --proxy-headers --forwarded-allow-ips=<proxy address>) and have the proxy passHoston; otherwise every request looks cross-site, so list the public origin intrusted_origins. - Double-submit token for single-page apps, as defence in depth: with
require_token=Trueevery response to a request without a valid token sets a random one (256 bits) in the__Host-csrftokencookie (readable by JavaScript,Secure,SameSite=Lax, the__Host-prefix keeps sibling subdomains from planting one), also inrequest.state.csrf_token. Requests with cookies must send it back inX-CSRF-Token; it's compared in constant time. A browser that sends neither Origin nor Referer passes with a valid token. HTML forms can't set headers, so withrequire_token=Trueevery form that changes something must be sent by JavaScript (fetchwith the header), the login form included; apps with plain HTML forms leave it off and rely on the origin checks, which protect them on their own. - WebSockets: browsers don't apply CORS to WebSocket handshakes, so any site could open one
with the user's cookies and talk to it (cross-site WebSocket hijacking). Handshakes with cookies
get the same checks and are closed with 1008 before they're accepted (the server answers 403);
handshakes without
Originaren't from a browser and go through. - Refusals are logged at INFO with the method and the reason (never cookies or tokens), and
counted in
fastapi_alcazaba.csrf.refused. A plain ASGI middleware: streaming responses still stream.
Security headers and body size¶
Two ASGI middlewares for what FastAPI leaves to the deployment:
from fastapi_alcazaba.security import BodySizeLimitMiddleware, SecurityHeadersMiddleware
app.add_middleware(
BodySizeLimitMiddleware,
max_size=1_048_576, # 1 MiB
paths={"/uploads": 50 * 1_048_576, "/internal/stream": None},
)
app.add_middleware(SecurityHeadersMiddleware) # last: the outermost
- Security headers: the OWASP REST ones:
X-Content-Type-Options: nosniff,X-Frame-Options: DENY,Referrer-Policy: no-referrerandCross-Origin-Opener-Policy: same-origin. Headers the app sets itself are never overwritten;headers=adds others or changes the defaults (None removes one). Add it last, the outermost, so that the answers of the middlewares inside it (BodySizeLimitMiddleware's 400 and 413) get them too. Starlette's 500 for an unhandled exception never does:ServerErrorMiddlewarewraps every middleware you add. - HSTS only on HTTPS requests: browsers ignore it over plain HTTP, and on a development
localhostit would stick. Behind a proxy that terminates TLS, let the server trust it (uvicorn --proxy-headers --forwarded-allow-ips=<proxy address>). - Content-Security-Policy:
default-src 'none'; frame-ancestors 'none'for everything that isn't HTML (nothing in a JSON response should load anything). HTML responses gethtml_csp, None by default, so that the Swagger UI and ReDoc, which load scripts from a CDN, keep working. - Body size: FastAPI has no limit of its own; a client could send an endless body that the app
reads into memory. A
Content-Lengthover the limit gets a 413 before the app runs, a malformed or ambiguous one a 400. Bodies without one (chunked) are counted while the app reads them and refused once they pass the limit, also when FastAPI parses them into a model.pathssets other limits by path prefix (the longest wins, None for no limit), matched like the routes, without theroot_path. - Both are plain ASGI middlewares, not
BaseHTTPMiddleware: streaming responses still stream, and WebSockets go through untouched.