Cookies & CSRF
Cookie Helper Module
Section titled “Cookie Helper Module”File: backend/app/utils/cookies.py
All cookie security attributes are centralized in this module to ensure consistency across auth endpoints.
is_https(request)
Section titled “is_https(request)”Determines whether the request was made over HTTPS:
- If
TRUSTED_PROXY=true(default) and the direct peer is a trusted proxy (TRUSTED_PROXIES, default private networks), reads theX-Forwarded-Protoheader. If present, checks whetherhttpsis in the comma-separated values. - 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.
oidc_cookie_kwargs(request)
Section titled “oidc_cookie_kwargs(request)”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.
auth_cookie_kwargs(request)
Section titled “auth_cookie_kwargs(request)”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.
auth_cookie_max_age()
Section titled “auth_cookie_max_age()”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).
local_access_cookie_max_age() / oidc_access_cookie_max_age(exp)
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()returnssettings.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 toauth_cookie_max_age()ifexpis 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.
Why SameSite=Lax (not None)
Section titled “Why SameSite=Lax (not None)”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:
-
The OIDC callback is a top-level GET navigation.
SameSite=Laxallows cookies to be sent on same-site top-level GET navigations. -
The OIDC provider and the application share the same eTLD+1. For example,
auth.example.com(OIDC provider) andapp.example.com(application) share the eTLD+1example.com. Since they are same-site,SameSite=Laxcookies are sent on the callback. -
SameSite=Nonecauses Firefox Total Cookie Protection (TCP) to partition the cookie. Firefox 104+ enables TCP by default. PartitionedSameSite=Nonecookies are stored per top-level site and may not be sent on cross-subdomain redirect callbacks, causing a 400 “Missing PKCE verifier cookie” error. -
SameSite=Laxcookies are NOT partitioned by Firefox TCP, so they are always sent on the callback.
CSRF Middleware
Section titled “CSRF Middleware”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-Withheader on all mutations (POST, PATCH, PUT, DELETE). - Exempt:
- Safe methods: GET, HEAD, OPTIONS (per RFC 7231)
/healthendpoint- E2E test mode (
e2e_test_mode=true) Bearer ptr_API tokens (authenticated via header, not cookie — no CSRF risk)
Login CSRF
Section titled “Login CSRF”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 Attributes Summary
Section titled “Cookie Attributes Summary”| 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 | / |
Common Issues
Section titled “Common Issues”“Missing PKCE verifier cookie”
Section titled ““Missing PKCE verifier cookie””This error occurs when the pkce_verifier cookie is not sent on the OIDC callback. Check:
- SameSite=Lax — Ensure cookies use
SameSite=Lax, notNone. Firefox TCP partitionsSameSite=Nonecookies - 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 ofnginx.conf.template. A custom proxy in front of the backend must keep them: without them nginx uses HTTP/1.0 with buffering, which can stripSet-Cookieheaders from 302 responses, and a header buffer smaller than the three token cookies (access, refresh, id) makes the callback fail with 502. --proxy-headers— Uvicorn must run with--proxy-headers(default in Dockerfile) so the backend trustsX-Forwarded-Protofrom the reverse proxy. Without this,is_https()returnsfalseover HTTPS behind a proxy, and theSecureflag is not set.
Cookie not sent on callback
Section titled “Cookie not sent on callback”- Firefox Network tab — Check the callback request for a
Cookieheader. If absent, the cookie was not sent. - Firefox Storage tab — Check the cookie attributes. If
SameSite=NoneandSecureis set, Firefox TCP may have partitioned it. Switch toSameSite=Lax. - Traefik compress middleware — If using Traefik, disable the
compressmiddleware for/auth/paths. It can stripSet-Cookieheaders from 302 redirect responses.
Related Documentation
Section titled “Related Documentation”- Authentication — Auth priority chain, OIDC flow, local auth, API tokens
- Debugging — Troubleshooting guide
- Reverse Proxy — nginx, Traefik, and Caddy configuration