Skip to content

Cookies & CSRF

File: backend/app/utils/cookies.py

All cookie security attributes are centralized in this module to ensure consistency across auth endpoints.

Determines whether the request was made over HTTPS:

  1. If TRUSTED_PROXY=true (default) and the direct peer is a trusted proxy (TRUSTED_PROXIES, default private networks), reads the X-Forwarded-Proto header. If present, checks whether https is in the comma-separated values.
  2. Falls back to request.url.scheme == "https".

This makes the Secure flag protocol-aware rather than tied to the DEBUG flag. It works over HTTP for local development without requiring DEBUG=true, and correctly sets Secure when behind a reverse proxy that terminates TLS.

Returns {"secure": is_https(request), "samesite": "lax", "path": "/"}.

Used for pkce_verifier and oauth_state cookies. SameSite=Lax is always used — see Why SameSite=Lax below. The path: "/" attribute ensures cookies are sent on all routes, including the OIDC callback at /auth/callback.

Returns {"secure": is_https(request), "samesite": "lax", "path": "/"}.

Used for access_token and refresh_token cookies. SameSite=Lax is always used. The path: "/" attribute ensures session cookies are available on all application routes. This function is safe for both set_cookie and delete_cookie calls.

Returns settings.refresh_token_expire_days * 86400 (7 days by default) — used only for the refresh_token cookie, so it persists across browser restarts. Not used on delete_cookie calls (Starlette’s delete_cookie does not accept max_age).

Section titled “local_access_cookie_max_age() / oidc_access_cookie_max_age(exp)”

The access_token cookie does not use auth_cookie_max_age() — it gets its own, deliberately shorter max_age so the cookie can never outlive the refresh_token cookie in the browser:

  • local_access_cookie_max_age() returns settings.access_token_expire_minutes * 60 (30min by default) for the local-auth path.
  • oidc_access_cookie_max_age(exp) returns the OIDC-issued token’s own remaining lifetime (capped to auth_cookie_max_age() if exp is somehow missing).

This means the browser deletes the access_token cookie entirely once its own lifetime elapses — see Token Refresh for why get_current_user must treat an absent access_token cookie the same as an expired one, not as “no session,” as long as a refresh_token cookie is still present.

The OIDC flow cookies (pkce_verifier, oauth_state) were initially set to SameSite=None; Secure to support cross-subdomain redirect callbacks. This was reverted to SameSite=Lax for the following reasons:

  1. The OIDC callback is a top-level GET navigation. SameSite=Lax allows cookies to be sent on same-site top-level GET navigations.

  2. The OIDC provider and the application share the same eTLD+1. For example, auth.example.com (OIDC provider) and app.example.com (application) share the eTLD+1 example.com. Since they are same-site, SameSite=Lax cookies are sent on the callback.

  3. SameSite=None causes Firefox Total Cookie Protection (TCP) to partition the cookie. Firefox 104+ enables TCP by default. Partitioned SameSite=None cookies are stored per top-level site and may not be sent on cross-subdomain redirect callbacks, causing a 400 “Missing PKCE verifier cookie” error.

  4. SameSite=Lax cookies are NOT partitioned by Firefox TCP, so they are always sent on the callback.

File: backend/app/middleware/csrf.py

The CSRF middleware enforces that state-changing requests include an X-Requested-With header. SPAs can set custom headers on fetch/XHR requests, but plain HTML forms cannot. This prevents CSRF because browsers enforce CORS preflight for non-simple requests.

  • Required: X-Requested-With header on all mutations (POST, PATCH, PUT, DELETE).
  • Exempt:
    • Safe methods: GET, HEAD, OPTIONS (per RFC 7231)
    • /health endpoint
    • E2E test mode (e2e_test_mode=true)
    • Bearer ptr_ API tokens (authenticated via header, not cookie — no CSRF risk)

The /auth/ prefix was previously exempt from the CSRF check. This meant POST /auth/login and POST /auth/register were vulnerable to login CSRF — an attacker could forge a cross-site request to log the victim in as the attacker.

The exemption was removed; login and registration now require X-Requested-With. The OIDC flow uses only GET requests (already exempt as safe methods).

Cookie Secure SameSite HttpOnly Max-Age Path
access_token (local) protocol-aware Lax true 30min (access_token_expire_minutes) /
access_token (OIDC) protocol-aware Lax true the token’s own remaining exp /
refresh_token protocol-aware Lax true 7 days (refresh_token_expire_days) /
id_token (OIDC) protocol-aware Lax true 7 days (refresh_token_expire_days) /
pkce_verifier protocol-aware Lax true 300s /
oauth_state protocol-aware Lax true 300s /

This error occurs when the pkce_verifier cookie is not sent on the OIDC callback. Check:

  1. SameSite=Lax — Ensure cookies use SameSite=Lax, not None. Firefox TCP partitions SameSite=None cookies
  2. nginx proxy config — The proxy directives (proxy_http_version 1.1, proxy_buffering off, proxy_request_buffering off, proxy_redirect off, proxy_buffer_size 16k) are inherited by /api/ and /auth/ from the server block of nginx.conf.template. A custom proxy in front of the backend must keep them: without them nginx uses HTTP/1.0 with buffering, which can strip Set-Cookie headers from 302 responses, and a header buffer smaller than the three token cookies (access, refresh, id) makes the callback fail with 502.
  3. --proxy-headers — Uvicorn must run with --proxy-headers (default in Dockerfile) so the backend trusts X-Forwarded-Proto from the reverse proxy. Without this, is_https() returns false over HTTPS behind a proxy, and the Secure flag is not set.
  1. Firefox Network tab — Check the callback request for a Cookie header. If absent, the cookie was not sent.
  2. Firefox Storage tab — Check the cookie attributes. If SameSite=None and Secure is set, Firefox TCP may have partitioned it. Switch to SameSite=Lax.
  3. Traefik compress middleware — If using Traefik, disable the compress middleware for /auth/ paths. It can strip Set-Cookie headers from 302 redirect responses.