Skip to content

Sessions, CSRF and security headers

What a cookie-based app needs: server-side sessions, CSRF protection, headers and body limits. In fastapi_alcazaba.security.

In the guide: Server-side sessions, CSRF protection, Security headers and body size. In an app: the example.

fastapi_alcazaba.security.ServerSessionMiddleware

ServerSessionMiddleware(app: ASGIApp, *, cache: Cache | None = None, cookie_name: str = '__Host-session', max_age: int = 14 * 24 * 3600, idle_timeout: int | None = None, secure: bool = True, same_site: SameSite = 'lax', path: str = '/', domain: str | None = None, max_size: int = 16384, namespace: str = 'session', users: UserSessions | None = None)

ASGI middleware that keeps each browser's session in the cache's backend, under a random id carried by a cookie (HttpOnly, Secure and SameSite by default).

app.add_middleware(ServerSessionMiddleware)
  • Saved only when it changed, when the response starts; an empty session isn't stored and gets no cookie. A session emptied by the app is deleted and its cookie expired.
  • New ids are always the server's: a cookie whose session doesn't exist (expired, made up) never becomes the id of a new one. rotate_session at login, clear_session at logout.
  • Expiry: max_age seconds after the session was created (or rotated), and after idle_timeout seconds without requests. Both are checked when it's read, and the backend drops it then too.
  • Backend failures: if the session can't be read, the request goes on with an empty one (anonymous: fail closed) and a warning; a change to it, or any failure to save, rotate or clear, replaces the response with a 503 and leaves the cookie alone, since what the response says (logged in, logged out) wouldn't be true. Several requests of the same session at once: the last one to save wins, but never against a logout or a rotation: the old id leaves a mark for the rest of its life, and a request still running with it can't bring it back.
  • Sealed with the cache's signing_key or encryption_key (use the latter when sessions hold personal data). Without either, whoever can write to the backend can forge sessions: a warning is logged.
  • WebSockets get websocket.session too, read-only.

Parameters:

Name Type Description Default
cache Cache | None

where sessions are kept; the app's (Cache.install) or FastAPICache's by default. With a TieredBackend, only its shared (remote) tier is used.

None
cookie_name str

the cookie. __Host- keeps sibling subdomains from setting one (cookie tossing); it needs secure, path / and no domain: name it otherwise for plain HTTP in development.

'__Host-session'
max_age int

seconds a session lasts from its creation; also the cookie's Max-Age.

14 * 24 * 3600
idle_timeout int | None

seconds without requests after which it's over; None for no limit.

None
secure bool

only send the cookie over HTTPS.

True
same_site SameSite

the cookie's SameSite: "lax", "strict" or "none" (needs secure).

'lax'
path str

the cookie's path.

'/'
domain str | None

the cookie's domain; None keeps it to the host that set it.

None
max_size int

bytes of JSON a session may take; more raises ValueError when it's saved.

16384
namespace str

part of the backend keys, after the cache's prefix (Cache.state_key).

'session'
users UserSessions | None

keeps each user's sessions (see UserSessions), to list them and end them, all of them at once too: sessions whose data has its user_key.

None

fastapi_alcazaba.security.rotate_session

rotate_session(connection: HTTPConnection) -> None

Give the session a new id, with the same data, when the response starts. Call it when the user logs in or gains privileges: an id someone planted in their browser before (session fixation) is deleted with the old session. The new session's max_age starts then.

fastapi_alcazaba.security.clear_session

clear_session(connection: HTTPConnection) -> None

Log out: empty the session, delete it from the backend and expire the cookie when the response starts. Data stored afterwards in the same request goes to a new session.

fastapi_alcazaba.security.ServerSession

ServerSession(data: dict[str, Any] | None = None)

Bases: dict[str, Any]

The session of a request (request.session): a dict of JSON values, saved when the response starts if it changed (nested changes too).

Once it's read the response depends on the cookie, and gets Vary: Cookie. Starlette 1.0+ tells it on every request.session; older ones don't, so its reads tell it too.

mark_accessed

mark_accessed() -> None

Called by request.session: the response then depends on the cookie.

fastapi_alcazaba.security.UserSessions

UserSessions(*, user_key: str = 'user_id', max_sessions: int = 50, record_client: bool = True, cache: Cache | None = None)

Each user's server sessions, to list them ("your devices") and end them: one, or all of them ("log out everywhere", a new password). Give it to the middleware:

user_sessions = UserSessions()
app.add_middleware(ServerSessionMiddleware, users=user_sessions)

await user_sessions.end_all(user.id, request=request, keep_current=True)

A session is the user's whose data has user_key (what SessionStrategy.login sets): the middleware keeps an index of each user's sessions in the backend, sealed like them, when they start, rotate and end.

  • Ending all of them also refuses, from then on, every session of the user that started before: those the index didn't hold too (two logins at once on a backend without locks, an index the backend lost). It costs one more read for each request with a user.
  • At most max_sessions per user: a login past it ends the oldest session.
  • The index holds each session's backend key (a digest: not a cookie), and its User-Agent and address when it started (record_client=False for neither).

Parameters:

Name Type Description Default
user_key str

the key of the session's data that holds its user's ID.

'user_id'
max_sessions int

sessions a user may have at once.

50
record_client bool

keep the User-Agent and the address of each session.

True
cache Cache | None

where the sessions are; the request's (get_cache) by default.

None

bind

bind(namespace: str, max_age: int) -> None

Called by the ServerSessionMiddleware it's given to: where its sessions are.

ended async

ended(cache: Cache, data: dict[str, Any], started: int) -> bool

Whether a session of data, started at started (Unix milliseconds), was ended by end_all.

follow async

follow(cache: Cache, loaded: _Loaded, session: ServerSession, key_of: Callable[[Cache, str], str], scope: Scope) -> None

Keep the index as the request left the session: started, rotated, ended, or with another user. A failure is logged: end_all refuses what the index misses.

list async

list(user_id: Any, *, request: HTTPConnection | None = None) -> list[SessionInfo]

The user's live sessions, the newest first; those that expired are dropped from the index. Raises BackendUnavailable when the backend fails.

end async

end(user_id: Any, ref: str, *, request: HTTPConnection | None = None) -> bool

End the session ref of the user (from list); False when it isn't one of theirs. Ending the request's own session logs it out once the response is sent.

end_all async

end_all(user_id: Any, *, request: HTTPConnection | None = None, keep_current: bool = False) -> int

End every session of the user (with keep_current, but the request's, which gets a new id); how many the index held. Sessions it didn't hold are refused too.

fastapi_alcazaba.security.SessionInfo dataclass

SessionInfo(ref: str, created: datetime, last_seen: datetime, user_agent: str | None, client: str | None, current: bool)

One of a user's sessions, as UserSessions.list gives it, for a page of "your devices".

Parameters:

Name Type Description Default
ref str

what names it to UserSessions.end: not its id, which only its cookie holds.

required
created datetime

when it started (the login, or its last rotation), in UTC.

required
last_seen datetime

when it was last saved or touched (idle_timeout touches it), in UTC.

required
user_agent str | None

the browser's User-Agent when it started, cut at 200 characters.

required
client str | None

the client's address when it started, as the server reports it.

required
current bool

it's the session of the request that asked.

required

fastapi_alcazaba.security.CSRFMiddleware

CSRFMiddleware(app: ASGIApp, *, trusted_origins: Iterable[str] = (), exempt_paths: Iterable[str] = (), require_token: bool = False, check_all: bool = False, cookie_name: str = '__Host-csrftoken', header_name: str = 'X-CSRF-Token', cookie_secure: bool = True, cookie_domain: str | None = None)

ASGI middleware that refuses, with a 403, unsafe requests that a page on another site made the user's browser send. See the module's documentation for the rules.

app.add_middleware(CSRFMiddleware)

The app's own origin is the scheme and the Host header as the server sees them. Behind a proxy that terminates TLS, let the server trust it (uvicorn --proxy-headers --forwarded-allow-ips=<proxy address>) so that the scheme is https, and have the proxy pass the Host header on; otherwise list the public origin in trusted_origins.

Parameters:

Name Type Description Default
trusted_origins Iterable[str]

other origins (https://admin.example.com) whose pages may send requests: sibling subdomains count as other origins, and so do identity providers and payment pages that post a form back to the app.

()
exempt_paths Iterable[str]

path prefixes that aren't checked (webhooks signed in their own way). They're matched like the app's routes: without the root_path a proxy or a mount adds.

()
require_token bool

also require, on requests with cookies, a header (header_name) equal to the token cookie (cookie_name) set on every response that lacks a valid one. The token is in request.state.csrf_token too. A request whose browser sends neither Origin nor Referer passes with a valid token.

False
check_all bool

also check requests without cookies or HTTP authentication: for apps that trust TLS client certificates or the network (an intranet), which the browser sends too.

False
cookie_name str

the token cookie. The __Host- prefix keeps sibling subdomains from setting one (cookie tossing); it needs cookie_secure and no cookie_domain.

'__Host-csrftoken'
header_name str

the header that carries the token.

'X-CSRF-Token'
cookie_secure bool

only send the token cookie over HTTPS.

True
cookie_domain str | None

the token cookie's domain, for pages on another subdomain that need to read it (then pick a name without the __Host- prefix).

None

fastapi_alcazaba.security.SecurityHeadersMiddleware

SecurityHeadersMiddleware(app: ASGIApp, *, headers: Mapping[str, str | None] | None = None, hsts: str | None = 'max-age=63072000; includeSubDomains', csp: str | None = "default-src 'none'; frame-ancestors 'none'", html_csp: str | None = None)

Adds the security headers of the OWASP REST Security Cheat Sheet to every response, without overwriting the ones the app sets itself.

app.add_middleware(SecurityHeadersMiddleware)
  • X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Referrer-Policy: no-referrer and Cross-Origin-Opener-Policy: same-origin on every response.
  • Strict-Transport-Security only on HTTPS requests: on plain HTTP browsers ignore it, and on a development localhost it would stick. Behind a proxy that terminates TLS, tell the server to trust it (uvicorn --proxy-headers --forwarded-allow-ips=<proxy address>) so that the request's scheme is https.
  • Content-Security-Policy: csp for responses that aren't HTML (nothing in a JSON response should load anything), html_csp for HTML ones. None by default, so that FastAPI's Swagger UI and ReDoc, which load scripts from a CDN, keep working.

Parameters:

Name Type Description Default
headers Mapping[str, str | None] | None

more headers, or other values for the defaults; None removes a default.

None
hsts str | None

the Strict-Transport-Security value; None leaves it out.

'max-age=63072000; includeSubDomains'
csp str | None

the Content-Security-Policy of responses that aren't HTML; None leaves it out.

"default-src 'none'; frame-ancestors 'none'"
html_csp str | None

the Content-Security-Policy of HTML responses.

None

fastapi_alcazaba.security.BodySizeLimitMiddleware

BodySizeLimitMiddleware(app: ASGIApp, *, max_size: int = 1048576, paths: Mapping[str, int | None] | None = None)

Refuses request bodies larger than max_size bytes with a 413. FastAPI has no limit of its own: a client could send an endless body that the app reads into memory.

app.add_middleware(BodySizeLimitMiddleware, max_size=1_048_576, paths={"/uploads": None})

A Content-Length over the limit is refused before the app runs. Bodies without one (chunked) are counted while the app reads them, and refused as soon as they pass the limit.

Parameters:

Name Type Description Default
max_size int

the limit, in bytes.

1048576
paths Mapping[str, int | None] | None

other limits for the paths starting with these prefixes (the longest matching prefix wins; None for no limit). They're matched like the app's routes: without the root_path a proxy or a mount adds in front.

None

limit

limit(path: str) -> int | None

The limit for requests to path; None when there's none.

fastapi_alcazaba.security.DEFAULT_SECURITY_HEADERS module-attribute

DEFAULT_SECURITY_HEADERS: Mapping[str, str] = {'X-Content-Type-Options': 'nosniff', 'X-Frame-Options': 'DENY', 'Referrer-Policy': 'no-referrer', 'Cross-Origin-Opener-Policy': 'same-origin'}