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_sessionat login,clear_sessionat logout. - Expiry:
max_ageseconds after the session was created (or rotated), and afteridle_timeoutseconds 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_keyorencryption_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.sessiontoo, read-only.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cache
|
Cache | None
|
where sessions are kept; the app's ( |
None
|
cookie_name
|
str
|
the cookie. |
'__Host-session'
|
max_age
|
int
|
seconds a session lasts from its creation; also the cookie's |
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 |
'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 ( |
'session'
|
users
|
UserSessions | None
|
keeps each user's sessions (see |
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_sessionsper 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-Agentand address when it started (record_client=Falsefor 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 |
True
|
cache
|
Cache | None
|
where the sessions are; the request's ( |
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 |
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 ( |
required |
user_agent
|
str | None
|
the browser's |
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 ( |
()
|
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 |
()
|
require_token
|
bool
|
also require, on requests with cookies, a header ( |
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-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 |
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-referrerandCross-Origin-Opener-Policy: same-originon every response.Strict-Transport-Securityonly on HTTPS requests: on plain HTTP browsers ignore it, and on a developmentlocalhostit 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:cspfor responses that aren't HTML (nothing in a JSON response should load anything),html_cspfor 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 |
'max-age=63072000; includeSubDomains'
|
csp
|
str | None
|
the |
"default-src 'none'; frame-ancestors 'none'"
|
html_csp
|
str | None
|
the |
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
|
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'}