ADR 0006: HttpOnly cookies for refresh tokens¶
Status¶
Accepted
Context¶
Refresh tokens were persisted in the SPA localStorage (tokenStorage.ts), which makes them readable to any XSS on the origin and enables full session takeover. The frontend already uses withCredentials and CSRF (fastapi-csrf-protect) for cookie-based CSRF defense. Issue #66 requires migrating refresh delivery/storage to HttpOnly cookies while keeping access tokens in memory only.
Decision¶
- Refresh token delivery: Backend sets an HttpOnly cookie on login / change-password; clears it on logout. Cookie name defaults to
refresh_token(REFRESH_TOKEN_COOKIE_NAME). - Cookie flags:
HttpOnly=truealwaysSecuredefaults to true whenMODE=production, otherwise false (localhost HTTP). Override withREFRESH_COOKIE_SECURE.SameSite=Laxby default (REFRESH_COOKIE_SAMESITE); usenoneonly with Secure for true cross-site API hosts.Pathscoped to{API_V1_STR}/authso the cookie is only sent to auth routes.- Optional
REFRESH_COOKIE_DOMAINfor shared parent domains (e.g..example.com). - Access token: Remains in JSON responses and SPA memory (Redux + module variable). Never written to
localStorage. - CSRF: Required on cookie-authenticated state-changing auth routes: login (existing),
new_access_token,logout, andchange_password(existing). - Redis allowlist: Unchanged. Refresh JWTs are still added to
user:{id}:refreshand cleared on logout. No refresh rotation in this change —/new_access_tokenreuses the existing refresh cookie/JWT and does not re-set the cookie (rotation remains a follow-up). - Client model: First-party SPA is cookie-primary. Optional JSON body
refresh_tokenon/auth/new_access_tokenis retained as a documented fallback for non-browser API clients; the SPA does not send it. - Session restore: SPA stores a non-secret
localStoragehint (auth_session_active) after successful login/refresh so it can attempt cookie refresh without probing on every anonymous visit.localStorage(notsessionStorage) because the hint must outlive the tab — the refresh cookie is valid forREFRESH_TOKEN_EXPIRE_MINUTES, so a new tab or browser restart must still be able to restore. The hint is not a credential; possessing it grants nothing without the HttpOnly cookie. LegacylocalStoragerefresh keys are cleared on logout/migrate. - Refresh-endpoint 401s:
/auth/new_access_tokenanswers 401 for a missing or expired refresh cookie. The SPA's 401 interceptor excludes that endpoint from its refresh-and-retry path, so a stale hint with no cookie fails once and logs out instead of recursing.
CORS / deploy notes¶
allow_credentials=Trueis already enabled. Browsers rejectAccess-Control-Allow-Origin: *with credentials — setBACKEND_CORS_ORIGINSto the exact frontend origin(s).- Frontend and API on different sites need either a shared cookie domain + appropriate SameSite, or SameSite=None; Secure.
Consequences¶
- XSS can no longer read refresh tokens via
document.cookie/localStorage. - Cross-origin and mobile non-browser clients must use the documented body fallback or a future dedicated API auth path.
- Existing sessions with only
localStoragerefresh tokens require re-login after deploy. - Docs that described localStorage refresh (and undocumented rotation) must be updated to match this ADR.