ADR 0054 : le refresh token vit en cookie HttpOnly, pas en Bearer/localStorage
Status: Accepted (livré
feature/dev, PR #543 WP-FE1 + fix #550)Date: 2026-05-19
Track: Software / Security
Related: ADR 0005 (JWT access/refresh, Bearer header) — amendée ici sur le seul point du transport et du stockage côté client, pas sur le format JWT ni la rotation.
Source :
docs/backend/JWT_REFRESH_TOKENS.md§”Amendment 2026-05-19” porte l’implémentation complète (backend + frontend + tests).
Contexte
ADR-0005 (2025-02-10) avait retenu Bearer + JWT en acceptant que le client
gère lui-même le stockage des deux tokens. En pratique, le flow d’origine
plaçait access token ET refresh token en localStorage côté frontend :
un vol de session par XSS y donnait un accès rejouable pendant toute la durée
de vie du refresh token (7 jours), pas seulement 15 minutes.
C’était un bloquant sécurité identifié avant la bêta fermée (WP-FE1, #343).
Décision
Access token : mémoire JS uniquement (
frontend/src/lib/accessToken.ts). Jamais persisté (nilocalStorage, nisessionStorage, ni cookie lisible par JS).Refresh token : cookie
HttpOnly; Secure; SameSite=Strict; Path=/api/v1/auth; Max-Age=7j, posé et lu côté backend uniquement (backend/src/infrastructure/web/auth_cookie.rs). Illisible par JavaScript, non rejouable hors du même site.Le corps JSON de
/auth/loginet/auth/refreshne porte plusrefresh_token: uniquement{ token, user }.POST /auth/logoutrévoque tous les refresh tokens de l’utilisateur et expire le cookie côté navigateur.Single-flight refresh (
frontend/src/stores/auth.ts,inflightRefresh) : dédoublonne les appels concurrents à/auth/refresh— sans quoi deux îlots Astro (RouteGuard+Navigation) hydratant en parallèle rejouent le même cookie, la rotation backend révoque le premier, le second échoue en 401, et l’utilisateur est déconnecté à chaque navigation (bug prod réel, fix #550).
Rotation et révocation instantanée (côté DB) restent inchangées par rapport à ADR-0005.
Alternatives considérées
Garder Bearer + localStorage, ajouter une détection XSS — rejeté : traite le symptôme, pas la cause ; une XSS réussie reste exploitable.
Access token également en cookie HttpOnly — rejeté pour l’instant : empêcherait l’API d’être appelée hors navigateur (mobile natif, Phase 2) sans double mécanisme. Le compromis retenu isole le seul artefact long-lived (refresh) derrière
HttpOnly, garde l’access token Bearer-compatible et court (15 min).
Conséquences
Positif :
Vol de session par XSS limité à la fenêtre de l’access token (15 min) — le refresh token n’est jamais exposé au JS.
Pas de session rejouable après fermeture du navigateur si le cookie expire ou est révoqué.
Négatif / à surveiller :
SameSite=Strictsuppose le front et l’API sur le même site (Traefik, domaine unique, API en/api/v1). Une bascule vers des sous-domaines séparés (Phase 2) exige une re-décision (Lax+COOKIE_DOMAIN).COOKIE_SECURE=falseest nécessaire en dev/E2E surhttp://localhost(sinon le navigateur rejette le cookie hors HTTPS) — ne jamais laisser cette valeur en configuration de production.
Tests (4 catégories)
Backend :
backend/tests/e2e_auth.rs—fe1_happy_login_sets_httponly_cookie_and_strips_body,fe1_security_refresh_via_cookie_rotates_and_body_has_no_refresh,fe1_negative_refresh_without_or_forged_cookie_is_401,fe1_edge_old_refresh_cookie_revoked_after_rotation.Frontend (Playwright) :
frontend/tests/e2e/smoke/AuthCookie.spec.ts.