A browser app: sessions, CSRF and security headers¶
A small shop rendered on the server, with plain HTML and a few lines of JavaScript. Who signed in
and the cart live in a server-side session
(ServerSessionMiddleware): the cookie only carries a random
id, which signing in replaces and signing out deletes, and 30 minutes without requests end it.
CSRFMiddleware refuses what pages on other sites make the
browser send, and with require_token=True also wants the page's token in a header.
SecurityHeadersMiddleware and BodySizeLimitMiddleware
add the security headers, with a Content-Security-Policy for the pages, and refuse large bodies. A
FastAPIRateLimiter slows down password guessing.
Run it¶
From a clone of the repository:
make deps # .venv, with every extra
source .venv/bin/activate
uvicorn examples.browser_app.main:app
Open http://localhost:8000 and sign in as ana, password correct horse battery staple.
The cookies are Secure: browsers only keep them from HTTPS sites and from http://localhost,
which they treat as secure. Use localhost, not a LAN address, to try it over plain HTTP. In
tests, give the client an https URL for the same reason:
TestClient(app, base_url="https://testserver").
Set SESSION_SIGNING_KEY (secrets.token_urlsafe(32)), the same in every process. Without it
each start makes up a throwaway one, and every session ends when the app restarts.
Try it¶
curl keeps Secure cookies from localhost too, so a cookie jar plays the browser. The first
page sets the CSRF token, in a cookie and in the page, and carries the security headers:
$ curl -i -c jar -b jar http://localhost:8000/
HTTP/1.1 200 OK
content-type: text/html; charset=utf-8
vary: Cookie
set-cookie: __Host-csrftoken=QkgoLDXsFG6SGZsxRY39XJJJWKwuVB1VQCAoC7HQIpc; Path=/; Secure; SameSite=Lax
x-content-type-options: nosniff
x-frame-options: DENY
referrer-policy: no-referrer
cross-origin-opener-policy: same-origin
content-security-policy: default-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'
<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Shop</title><meta name="csrf-token" content="QkgoLDXsFG6SGZsxRY39XJJJWKwuVB1VQCAoC7HQIpc"><script src="/app.js" defer></script></head><body><p><a href="/login">Sign in</a></p>...
No session yet: it's only created when there's something to keep. A form posted without the
token is refused, even from the app's own page (Sec-Fetch-Site: same-origin, as browsers send):
$ curl -i -c jar -b jar -X POST http://localhost:8000/cart -H "Sec-Fetch-Site: same-origin" \
-d product=Figs
HTTP/1.1 403 Forbidden
{"detail":"Missing or invalid CSRF token"}
With the token in X-CSRF-Token, as app.js sends it, the cart gets a session:
$ curl -i -c jar -b jar -X POST http://localhost:8000/cart -H "Sec-Fetch-Site: same-origin" \
-H "X-CSRF-Token: QkgoLDXsFG6SGZsxRY39XJJJWKwuVB1VQCAoC7HQIpc" -d product=Figs
HTTP/1.1 303 See Other
location: /
set-cookie: __Host-session=ozlYn1TlYGLpFmUZlKT0eArPzN2hQZkbFLVVbPWV1II; Max-Age=28800; Path=/; HttpOnly; Secure; SameSite=Lax
cache-control: no-store
Signing in gives the session a new id, with the same cart:
$ curl -i -c jar -b jar -X POST http://localhost:8000/login -H "Sec-Fetch-Site: same-origin" \
-H "X-CSRF-Token: QkgoLDXsFG6SGZsxRY39XJJJWKwuVB1VQCAoC7HQIpc" \
-d username=ana --data-urlencode "password=correct horse battery staple"
HTTP/1.1 303 See Other
location: /
set-cookie: __Host-session=s4wi5n6b_DRAvO0aFKJ7gzK6lyPtWymwDfZ8Xqm8AJ0; Max-Age=28800; Path=/; HttpOnly; Secure; SameSite=Lax
$ curl -b jar http://localhost:8000/
...<p>Signed in as ana.</p>...<h2>Cart</h2><ul><li>Figs: 1</li></ul></body></html>
The old id is worth nothing now, so one planted in the browser before signing in (session fixation) gives nothing away:
$ curl http://localhost:8000/ -H "Cookie: __Host-session=ozlYn1TlYGLpFmUZlKT0eArPzN2hQZkbFLVVbPWV1II"
...<p><a href="/login">Sign in</a></p>...
A page on another site can't sign the user out, even if it had the token:
$ curl -i -b jar -X POST http://localhost:8000/logout -H "Sec-Fetch-Site: cross-site" \
-H "Origin: https://evil.example" -H "X-CSRF-Token: QkgoLDXsFG6SGZsxRY39XJJJWKwuVB1VQCAoC7HQIpc"
HTTP/1.1 403 Forbidden
x-frame-options: DENY
content-security-policy: default-src 'none'; frame-ancestors 'none'
{"detail":"Cross-site request refused"}
Signing out deletes the session from the backend: a copy of the cookie doesn't work either:
$ curl -i -c jar -b jar -X POST http://localhost:8000/logout -H "Sec-Fetch-Site: same-origin" \
-H "X-CSRF-Token: QkgoLDXsFG6SGZsxRY39XJJJWKwuVB1VQCAoC7HQIpc"
HTTP/1.1 303 See Other
location: /
set-cookie: __Host-session=; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Path=/; HttpOnly; Secure; SameSite=Lax
$ curl http://localhost:8000/ -H "Cookie: __Host-session=s4wi5n6b_DRAvO0aFKJ7gzK6lyPtWymwDfZ8Xqm8AJ0"
...<p><a href="/login">Sign in</a></p>...
Bodies over 64 KiB are refused before the app reads them:
$ head -c 100000 /dev/zero | tr '\0' x > big.txt
$ curl -i -b jar -X POST http://localhost:8000/cart --data-binary @big.txt
HTTP/1.1 413 Content Too Large
x-content-type-options: nosniff
{"detail":"Request body too large"}
What to look at¶
- The order of the middlewares: each one added wraps those before it. The session is the innermost; CSRF goes outside it, so a refused request never reads a session from the backend; the security headers are the outermost, so the 403s and 413s of the others get them too.
require_token=Trueneeds JavaScript: the middleware reads the token from theX-CSRF-Tokenheader, which a plain HTML form can't set, soapp.jssends the forms withfetch, taking the token from the page's<meta>(request.state.csrf_token). An app whose forms must work without JavaScript leavesrequire_tokenoff: the origin checks alone already stop CSRF in every current browser, login CSRF included.- A strict Content-Security-Policy for the pages: only the app's own script, no inline code
(so the token goes in a
<meta>and the script in its own file), no framing, forms only to the app. The JSON responses keep the defaultdefault-src 'none'./docsis off: a site has no use for it, and its CDN scripts wouldn't pass the policy. rotate_sessionat sign-in,clear_sessionat sign-out: the first defeats session fixation, the second makes a stolen cookie worthless at once. The__Host-prefix keeps a sibling subdomain from planting either cookie.- Small, escaped sessions: the cart only holds products of an enum, at most 10 of each, so a
session can't outgrow
max_size; everything that reaches the HTML goes throughhtml.escape. - Passwords: a salted scrypt hash, computed in a thread (it's slow on purpose) and for unknown users too, so the time doesn't tell who has an account; five attempts a minute per address.
The code¶
"""A small shop rendered on the server, for browsers: a session cookie, CSRF protection and the
security headers.
uvicorn examples.browser_app.main:app
Then open http://localhost:8000 and sign in as "ana", password "correct horse battery staple".
The cookies are Secure; browsers accept them from http://localhost, which they treat as secure.
- `ServerSessionMiddleware` keeps each session (who signed in, the cart) in the cache's backend;
the cookie only carries a random id. Signing in gives a new id, signing out deletes the
session, and 30 minutes without requests end it.
- `CSRFMiddleware` refuses what pages on other sites make the browser send. With
`require_token=True` it also wants the page's token in the `X-CSRF-Token` header: app.js sends
the forms with it.
- `SecurityHeadersMiddleware` adds the security headers, with a Content-Security-Policy that only
lets the pages run the app's own script; `BodySizeLimitMiddleware` refuses large bodies.
The sessions live in memory here; in production, in Redis or Valkey, so that every process sees
them, and a sign-out at once. Set SESSION_SIGNING_KEY (secrets.token_urlsafe(32)), in the
environment or a .env file.
"""
import hashlib
import hmac
import secrets
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from enum import StrEnum
from html import escape
from typing import Annotated
import uvicorn
from fastapi import Depends, FastAPI, Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse, Response
from pydantic import Field, SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict
from starlette.concurrency import run_in_threadpool
from fastapi_alcazaba import FastAPICache, FastAPIRateLimiter
from fastapi_alcazaba.backends.inmemory import InMemoryBackend
from fastapi_alcazaba.security import (
BodySizeLimitMiddleware,
CSRFMiddleware,
SecurityHeadersMiddleware,
ServerSessionMiddleware,
clear_session,
rotate_session,
)
# Only the app's own script, styles and images; no plugins, no framing, forms only to the app.
CSP = "default-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'"
APP_JS = """\
// Sends the forms with fetch, adding the CSRF token in the header that CSRFMiddleware checks:
// a plain form can't set headers.
const token = document.querySelector('meta[name="csrf-token"]').content;
for (const form of document.querySelectorAll("form[method=post]")) {
form.addEventListener("submit", async (event) => {
event.preventDefault();
const response = await fetch(form.action, {
method: "POST",
headers: {"X-CSRF-Token": token},
body: new URLSearchParams(new FormData(form)),
});
if (response.redirected) location.assign(response.url);
else alert(`Refused: ${response.status} ${response.statusText}`);
});
}
"""
class Product(StrEnum):
# An enum: the cart only holds what the shop sells, so sessions stay small.
OLIVE_OIL = "Olive oil"
ALMONDS = "Almonds"
FIGS = "Figs"
def scrypt(password: str, salt: bytes) -> bytes:
return hashlib.scrypt(password.encode(), salt=salt, n=2**14, r=8, p=1)
class Users:
"""Stands in for the users table: a salt and a slow hash of each password, never the
password itself."""
def __init__(self) -> None:
salt = secrets.token_bytes(16)
self.rows = {"ana": (salt, scrypt("correct horse battery staple", salt))}
def check(self, username: str, password: str) -> bool:
# Hashed for unknown users too: the time it takes doesn't tell who has an account.
salt, stored = self.rows.get(username, (b"\0" * 16, b""))
return hmac.compare_digest(scrypt(password, salt), stored)
users = Users()
def throwaway_secret() -> SecretStr:
"""A random secret, for trying the example: it changes at every start and differs between
processes."""
return SecretStr(secrets.token_urlsafe(32))
class Settings(BaseSettings):
"""The configuration, from environment variables or a .env file."""
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
session_signing_key: SecretStr = Field(default_factory=throwaway_secret)
settings = Settings()
@asynccontextmanager
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
# Redis or Valkey in production. Signed: whoever can write to the backend can't forge a
# session (encryption_key instead, with the crypto extra, if they hold personal data).
FastAPICache.init(InMemoryBackend(), prefix="shop", signing_key=settings.session_signing_key)
yield
# No /docs: a site doesn't need them, and their CDN scripts wouldn't pass the CSP.
app = FastAPI(lifespan=lifespan, docs_url=None, redoc_url=None, openapi_url=None)
# Each middleware added wraps the ones before it: the first is the innermost.
app.add_middleware(
ServerSessionMiddleware,
max_age=8 * 3600,
idle_timeout=30 * 60,
)
app.add_middleware(CSRFMiddleware, require_token=True) # outside: refusals don't read sessions
app.add_middleware(BodySizeLimitMiddleware, max_size=64 * 1024) # forms are small
# The outermost: the refusals of the middlewares inside it get the headers too.
app.add_middleware(SecurityHeadersMiddleware, html_csp=CSP)
def page(request: Request, body: str) -> HTMLResponse:
"""The page around `body`, with the CSRF token for app.js."""
token = escape(request.state.csrf_token)
return HTMLResponse(
'<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Shop</title>'
f'<meta name="csrf-token" content="{token}"><script src="/app.js" defer></script>'
f"</head><body>{body}</body></html>"
)
def form(action: str, button: str, **fields: str) -> str:
inputs = "".join(
f'<input type="hidden" name="{name}" value="{escape(value)}">'
for name, value in fields.items()
)
return f'<form method="post" action="{action}">{inputs}<button>{button}</button></form>'
@app.get("/")
async def home(request: Request) -> HTMLResponse:
user = request.session.get("user")
if user is None:
header = '<p><a href="/login">Sign in</a></p>'
else:
header = f"<p>Signed in as {escape(user)}.</p>{form('/logout', 'Sign out')}"
products = "".join(
f"<li>{escape(product)} {form('/cart', 'Add to cart', product=product)}</li>"
for product in Product
)
cart = request.session.get("cart", {})
items = "".join(f"<li>{escape(p)}: {cart[p]}</li>" for p in Product if p in cart)
return page(request, f"{header}<h1>Shop</h1><ul>{products}</ul><h2>Cart</h2><ul>{items}</ul>")
@app.get("/login")
async def login_page(request: Request, failed: bool = False) -> HTMLResponse:
error = "<p>Wrong username or password.</p>" if failed else ""
return page(
request,
f"<h1>Sign in</h1>{error}"
'<form method="post" action="/login">'
'<label>Username <input name="username" autocomplete="username" required></label> '
'<label>Password <input name="password" type="password" '
'autocomplete="current-password" required></label> <button>Sign in</button></form>',
)
# Five attempts a minute per client address.
@app.post("/login", dependencies=[Depends(FastAPIRateLimiter(times=5, minutes=1))])
async def login(
request: Request,
username: Annotated[str, Form(max_length=100)],
password: Annotated[str, Form(max_length=200)],
) -> RedirectResponse:
# In a thread: scrypt is slow on purpose, and would hold up the other requests.
if not await run_in_threadpool(users.check, username, password):
return RedirectResponse("/login?failed=true", status_code=303)
request.session["user"] = username
rotate_session(request) # a new id: one planted in the browser before is worthless now
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)
@app.post("/cart")
async def add_to_cart(request: Request, product: Annotated[Product, Form()]) -> RedirectResponse:
cart = request.session.setdefault("cart", {})
cart[product.value] = min(cart.get(product.value, 0) + 1, 10) # at most 10 of each
return RedirectResponse("/", status_code=303)
@app.get("/app.js")
async def script() -> Response:
return Response(APP_JS, media_type="text/javascript")
if __name__ == "__main__":
uvicorn.run("main:app", reload=True)