Skip to content

A browser app: sessions, CSRF and security headers

A browser app Browser to Your app: GET /. Your app to Browser: page, CSRF token, CSP. Browser to Your app: POST /cart, X-CSRF-Token. Your app to Sessions: the cart, in the session. Your app to Browser: 303 → /. Browser to Your app: another site's POST /logout. Your app to Browser: 403, cross-site. Sessions on the server; forms only from your own pages. Browser Your app Sessions GET / page, CSRF token, CSP POST /cart, X-CSRF-Token the cart, in the session 303 → / another site's POST /logout 403, cross-site Sessions on the server; forms only from your own pages
What it's for: a server-rendered app whose sessions live on the server, whose forms only its own pages can send, and whose pages carry the 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=True needs JavaScript: the middleware reads the token from the X-CSRF-Token header, which a plain HTML form can't set, so app.js sends the forms with fetch, taking the token from the page's <meta> (request.state.csrf_token). An app whose forms must work without JavaScript leaves require_token off: 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 default default-src 'none'. /docs is off: a site has no use for it, and its CDN scripts wouldn't pass the policy.
  • rotate_session at sign-in, clear_session at 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 through html.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

examples/browser_app/main.py
"""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)