0023 — HttpOnly cookie sessions with rotating, revocable refresh tokens
- Status: proposed
- Date: 2026-09-05
- Deciders: maintainers (@martinezsalmeron)
- Tags: security, auth, frontend
Context
Issue #353 (external audit, the most substantive finding). Verified on main today:
frontend/app/composables/useAuth.tsstores both tokens in JS-readable cookies (useCookie('access_token'),useCookie('refresh_token'),secureonly in prod,SameSite=Lax, 7-daymaxAgeon both) anduseApireads the access cookie to buildAuthorization: Bearer …on every call; on a 401 it callsauth.refresh()and retries once.- The refresh token is a stateless JWT (
create_refresh_token, 7 days).POST /auth/refreshdecodes it, checkstype == "refresh", the user is active andtoken_versionmatches, and mints a new pair. Nothing invalidates the old refresh token: it stays valid until expiry unlesstoken_versionis bumped for the whole user (which logs out every device). - The copilot SSE stream (
useCopilotStream.ts) isfetch()with the sameAuthorizationheader — EventSource can't set headers. - The e2e fixture (
frontend/tests/e2e/_fixtures.ts) logs in via the API and pins theaccess_tokencookie by hand. - There is no backend logout endpoint:
useAuth.logout()only clears the two cookies client-side, so both JWTs stay valid until expiry. - Tokens are minted by
/auth/setup(first-run wizard),/auth/login,/auth/refreshand/auth/set-password(invite flow); all four must move together.
Risk: any XSS (no CSP yet — #355) reads a 7-day refresh token from document.cookie, and a stolen refresh token can be replayed for its whole lifetime with no server-side way to revoke just that token.
Decision
- Tokens become
HttpOnly; Secure; SameSite=Laxcookies set by the backend, never readable by JS:/auth/setup,/auth/login,/auth/refresh,/auth/set-password(and/auth/mfa/verify, if ADR 0022's deferred TOTP ever ships) respond withSet-Cookiefordp_access(path/, TTL = access TTL 15 min) anddp_refresh(path/api/v1/auth/refresh, TTL = refresh TTL) — the refresh cookie is only ever sent to the one endpoint that needs it.- The JSON body keeps returning the access token during a one-release transition so third-party API clients aren't broken; the frontend stops reading it.
useApisendscredentials: 'include'and drops theAuthorizationheader for same-origin calls; the backend's bearer dependency accepts either the header or thedp_accesscookie (header wins), so scripts and the Zapier public API (dp_ tokens) keep working unchanged; the SSEfetch()drops the header and sendscredentials: 'include'instead. - The access JWT carries the refresh
family_idas asidclaim, so endpoints that never see the path-scoped refresh cookie can still name the session. - A new
POST /auth/logoutclears both cookies and revokes the family named bysid(below). - The refresh limiter (
_refresh_rate_key) reads the token from the cookie when the body has none; otherwise every cookie-flow refresh collapses to the proxy IP bucket it was written to avoid. - SSR:
auth.init()/useApion the server forward the incomingCookieheader (useRequestHeaders(['cookie']));isAuthenticatedderives from a successful/me, not from cookie presence, since JS can no longer see the cookie.
- Refresh rotation with server-side revocation (per-token, not per-user):
- New core table
auth_refresh_tokens(id/jti PK, user_id, family_id, issued_at, expires_at, revoked_at, replaced_by, user_agent_hash, last_ip). Every refresh JWT carriesjtiandfamily_id. /auth/refresh: verify JWT → look upjti→ must be unrevoked and unexpired → revoke it, mint a new one in the same family (replaced_byset), return the new pair. Presenting an already-revoked jti is treated as theft: the whole family is revoked (classic rotation-with-reuse-detection) and the user must log in again.token_versionstays as the "log out everywhere" hammer (password change, MFA enable/reset); rotation is the scalpel.- Logout revokes the family in the access token's
sid; an admin "sign out all sessions" for a member revokes all families for that user. - Expired rows are pruned by the existing scheduler (daily job in core, not a module).
- New core table
- CSRF posture: there is no custom-header requirement today, and
SameSite=Laxalone is not enough for the state-changing endpoints once auth is a cookie, because Lax still sends cookies on top-level GET navigations. Decision: double-submit token —/auth/loginalso sets a non-HttpOnlydp_csrfcookie;useApiechoes it asX-CSRF-Tokenon every non-GET; a core dependency rejects unsafe methods whose header ≠ cookie. Public endpoints (webhooks,dp_bearer API, public quote links) are exempt by construction — they never carry the session cookie. - Same-origin is a requirement, not an option: cookies + CSRF assume the API is served under the app's origin (the Caddy
/api/*route already does this;docker-compose.ymldev keepslocalhost:3000→localhost:8000cross-origin, so dev needsSameSite=Lax+ CORScredentials: trueon the backend forhttp://localhost:3000— already the case for the header flow).Secureis set only whenENVIRONMENT=production(Safari dropsSecurecookies onhttp://localhost), mirroring today'ssecure: import.meta.env.PROD.
Consequences
Good
- XSS can no longer exfiltrate a session; the refresh token never leaves the
/auth/refreshpath; a stolen refresh token is single-use and its reuse burns the family. - Per-device sign-out becomes possible (families ≈ devices).
- API clients using bearer headers (Zapier tokens, scripts, the e2e fixture's direct API calls) are untouched.
Bad / accepted trade-offs
useApi,useAuth, the SSE composable and the e2e fixture all change; the fixture moves from "set cookie by hand" to "call/loginthrough the page context so the browser stores the HttpOnly cookies". One PR touches ~6 files on the frontend and 3 on the backend.- A DB write per refresh (one row insert + one update every 15 min per active session) — negligible at clinic scale, and it's what makes revocation possible.
- The transition release returns tokens in the body and sets cookies; the body field is removed one release later (documented in the CHANGELOG and the public-API docs).
Alternatives considered
- Keep bearer-in-JS, add CSP only (#355) — CSP is defense-in-depth, not a substitute; one missed inline handler and the 7-day token walks.
token_versionbump on every refresh — logs out every other device of the user on each refresh; correct but unusable.- Stateless rotation (embed previous jti in the new token) — can't revoke on reuse without state; the table is the point.
- Opaque session ids instead of JWTs — clean, but a bigger rewrite (every dependency decoding claims); JWT access + stateful refresh is the incremental path.
How to verify the rule still holds
tests/test_auth_cookies.py(with the implementation): login setsHttpOnlycookies and the body still carries the token in the transition release; a request with only the cookie is authenticated; refresh rotates (old jti rejected, family revoked on reuse); logout revokes thesidfamily and a later refresh with that family's cookie is 401; CSRF header mismatch on POST is 403;/setupand/set-passwordset the same cookies as/login.- e2e: the login fixture no longer writes
document.cookie; a grep foruseCookie('access_token'/useCookie('refresh_token'infrontend/appreturns nothing.
References
frontend/app/composables/useAuth.ts,useApi.tsbackend/app/core/auth/router.py—/setup,/login,/refresh,/set-password,_refresh_rate_key(/logoutis new)backend/app/core/auth/service.py—create_refresh_tokenbackend/app/modules/copilot/frontend/composables/useCopilotStream.tsfrontend/tests/e2e/_fixtures.ts- Issue #353; ADR 0022 (deferred TOTP; its handshake would share
/login); issue #355 (CSP)