Rustine

Cookie httpOnly : où mettre le token, et ce que ça ne protège pas

Un JWT dans localStorage est lisible par n'importe quel script chargé sur ta page. La correction tient en un en-tête : pose ton refresh token dans un cookie __Host-session en HttpOnly; Secure; SameSite=Lax, et garde l'access token en mémoire JavaScript. Ce que ça règle : un XSS ne peut plus exfiltrer la session. Ce que ça ne règle pas : l'attaquant peut toujours appeler ton API avec ce cookie, puisque le navigateur l'envoie aussi sur les requêtes fetch(). Voici le Set-Cookie exact, pourquoi Path ne protège rien, et le CSRF qui revient dès que tu sors du same-site.

Sommaire
  1. HttpOnly ne bloque pas l'attaque, il bloque l'export
  2. Le Set-Cookie exact
  3. __Host- : la seule garantie de périmètre qui tienne
  4. « SameSite est Lax par défaut » : vrai, et insuffisant
  5. Le CSRF revient quand tu sors du same-site
  6. Access token en mémoire, refresh en rotation
  7. Quatre erreurs qui annulent la config
  8. Le prompt à coller dans ton agent
  9. Vérifier en 60 secondes
  10. Ce qu'il reste à faire après

HttpOnly ne bloque pas l'attaque, il bloque l'export

MDN est explicite : l'attribut interdit à JavaScript de lire le cookie, mais un cookie créé avec HttpOnly est quand même envoyé avec les requêtes initiées par JavaScript, fetch() compris.

Avec localStorage, le script lit le token, l'envoie sur un serveur distant, et l'attaquant le rejoue pendant toute sa durée de vie depuis n'importe quelle IP. Tu le découvres dans tes logs trois jours plus tard, sur une connexion depuis un datacenter que ton utilisateur n'a jamais visité. Avec un cookie HttpOnly, le script ne peut pas sortir le token : il peut agir, mais seulement tant que son code tourne dans cet onglet. Onglet fermé, faille corrigée, le rejeu devient impossible. Tu échanges un vol permanent contre une fenêtre d'abus temporaire.

L'XSS lui-même n'est pas traité par HttpOnly, qui n'est que la deuxième ligne : il faut une Content Security Policy, le guide complet est ici.

code
Set-Cookie: __Host-session=...; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=604800

Trois choses comptent : le préfixe __Host-, le Path=/ (obligatoire avec ce préfixe), et l'absence totale d'attribut Domain.

En Next.js (App Router), depuis un Route Handler :

ts
// app/api/auth/login/route.ts
import { NextResponse } from "next/server";

export async function POST(req: Request) {
  const { email, password } = await req.json();
  const { refreshToken } = await signIn(email, password);

  const res = NextResponse.json({ ok: true });
  res.cookies.set("__Host-session", refreshToken, {
    httpOnly: true, secure: true, sameSite: "lax", path: "/",
    maxAge: 60 * 60 * 24 * 7,
    // pas de `domain` : il casserait le préfixe __Host-
  });
  return res;
}

En Express : res.cookie("__Host-session", token, { httpOnly: true, secure: true, sameSite: "lax", path: "/", maxAge: 604800_000 }). Côté client, fetch("/api/me", { credentials: "include" }) dès que l'API est sur un autre host. Côté conformité, un cookie de session strictement nécessaire est exempté de consentement — les cas limites sont dans Cookies et CNIL.

__Host- : la seule garantie de périmètre qui tienne

Sans préfixe, n'importe qui contrôlant un sous-domaine de ton domaine peut poser un cookie du même nom avec Domain=.example.com — un vieux staging oublié, un blog marketing, un tunnel de paiement. Ton serveur lit le cookie de l'attaquant au lieu du tien, et la session est détournée sans une seule ligne de code exécutée chez toi.

Le préfixe ferme ça. Trois conditions imposées par le navigateur, d'après MDN : Secure posé depuis une page HTTPS, aucun Domain, Path=/. Le cookie ne part alors que vers l'hôte qui l'a posé, il vaut pour tout l'hôte, et il ne peut pas être écrasé sur un autre chemin. L'origine devient la frontière, plus le chemin. Les préfixes plus récents __Http- et __Host-Http- exigent en plus HttpOnly (Chrome 140+, Firefox 143+, rien côté Safari) : adoptables sans rien casser, garantie partielle.

« SameSite est Lax par défaut » : vrai, et insuffisant

Primo, le défaut n'existe pas partout. MDN donne Lax par défaut sur Chrome 80+, Edge 86+, Chrome Android 80+, Opera 71+ et Samsung Internet 13+ ; Firefox et Safari ne l'appliquent pas. Aucune raison de parier sur un défaut : écris SameSite=Lax.

Secundo, le Lax appliqué par défaut n'est pas le Lax explicite. Dans ce cas, le navigateur utilise une variante plus permissive : le cookie part aussi sur les POST inter-sites, à condition d'avoir été posé moins de deux minutes avant la requête. C'est une fenêtre de login-CSRF que presque personne ne documente, et elle disparaît dès que tu écris l'attribut toi-même.

Pas Strict pour autant : un utilisateur qui arrive depuis un lien dans un mail ou depuis Google n'envoie pas son cookie sur la première requête, voit une page déconnectée, clique, et là ça marche. Lax est le bon compromis pour un cookie de session.

Le CSRF revient quand tu sors du same-site

Tant que ton front et ton API partagent le même domaine enregistrable, Lax suffit : un formulaire hébergé sur evil.com ne déclenche aucun envoi de cookie en POST. Ça casse dans un cas — API sur un site différent du front, widget embarqué chez tes clients. Tu passes en SameSite=None; Secure et tu perds la protection CSRF d'un coup. Il faut la remettre à la main :

ts
// à appeler avant tout traitement mutant
const ALLOWED = new Set(["https://app.example.com"]);

export function assertOrigin(req: Request) {
  if (req.method === "GET" || req.method === "HEAD") return;
  const origin = req.headers.get("origin");
  if (!origin || !ALLOWED.has(origin)) throw new Response("CSRF", { status: 403 });
}

Contrôle Origin, pas Referer : il part sur les POST déclenchés par fetch comme sur les soumissions de formulaire, et il n'est pas vidé par les politiques qui tronquent le Referer.

Access token en mémoire, refresh en rotation

L'access token dure 10 à 15 minutes et vit dans une variable JS (let accessToken), jamais dans localStorage — attention à zustand/persist et redux-persist, qui réécrivent dans localStorage sans que tu le voies. Le refresh token, lui, est le cookie __Host-, à usage unique, qui tourne à chaque rafraîchissement.

Le coût de la mémoire : un F5 vide la variable, donc ton app fait un POST /api/auth/refresh au démarrage. Et un token en mémoire n'est pas une garantie magique : un secret codé en dur dans le bundle reste lisible — c'est le sujet de Clé API exposée dans le JavaScript.

Le serveur doit pouvoir détecter le rejeu, sinon la rotation ne sert à rien :

sql
create table sessions (
  id         uuid primary key default gen_random_uuid(),
  user_id    uuid not null references users(id) on delete cascade,
  family_id  uuid not null,  -- une famille = une connexion
  token_hash text not null,  -- sha256 du refresh, jamais la valeur en clair
  rotated_at timestamptz,
  revoked_at timestamptz
);
create index sessions_family_idx on sessions (family_id);

Dans POST /api/auth/refresh, tu cherches la ligne par sha256(token) reçu. Si revoked_at est rempli, quelqu'un présente un token déjà tourné : c'est un vol, ou une copie qui traîne dans un cache. Tu révoques la famille entière (update sessions set revoked_at = now() where family_id = $1), tu déconnectes l'utilisateur, tu loggues l'IP. Si c'est le token courant, tu le marques rotated_at et tu renvoies un nouveau cookie. C'est la seule mécanique qui transforme un token volé en incident détecté, au lieu d'un accès silencieux de trois semaines.

Dernier rappel : un cookie valide prouve qui parle, pas ce que la personne a le droit de lire. Si ton API fait select * from invoices where id = params.id sur la foi du cookie, il manque un contrôle d'accès — la faille IDOR en détail — et HttpOnly n'y change rien. Sur Supabase, ce contrôle s'écrit dans les policies RLS, pas dans le client.

Quatre erreurs qui annulent la config

Poser domain: ".example.com". Tu perds __Host- et tu livres le cookie à tous tes sous-domaines, y compris celui que tu as oublié.

Mettre Path=/api/auth « pour isoler le refresh ». Ça réduit la surface d'envoi, ce qui est réel — moins de logs, moins de proxies qui recopient le cookie. Mais MDN le dit noir sur blanc : Path n'est pas une mesure de sécurité et ne protège pas contre la lecture du cookie depuis un autre chemin. Incompatible avec __Host-, en plus.

Un logout qui ne supprime rien. Pour effacer un cookie, il faut renvoyer les mêmes attributs avec Max-Age=0 :

ts
res.cookies.set("__Host-session", "", {
  path: "/", maxAge: 0, httpOnly: true, secure: true, sameSite: "lax",
});

Un path différent, et ta route de déconnexion répond 200 en laissant la session vivante.

Compter sur un Max-Age court comme protection. Sept jours d'exposition valent mieux qu'un an, mais la seule chose qui ferme une session volée est la révocation côté serveur, pas le minuteur du navigateur.

Le prompt à coller dans ton agent

code
Cherche tous les tokens d'authentification écrits dans localStorage ou sessionStorage.
Déplace-les dans un cookie HttpOnly posé par le serveur, nommé __Host-session, avec
Path=/, Secure, SameSite=Lax, Max-Age 7 jours, aucun attribut Domain. Ajoute une route
/api/auth/refresh qui fait tourner le token et révoque la famille de sessions si un
ancien token revient. Vérifie qu'aucune réponse d'API ne renvoie le token en JSON.

Vérifier en 60 secondes

bash
curl -si -X POST https://app.example.com/api/auth/login -d '{"email":"a@b.fr","password":"..."}' | grep -i '^set-cookie'

Tu dois voir __Host-session=...; Path=/; Secure; HttpOnly; SameSite=Lax, sans Domain= ni SameSite=None. Dans la console, document.cookie ne doit jamais contenir __Host-session : s'il apparaît, l'attribut n'a pas été appliqué.

Piège de dev : sur http://localhost, les navigateurs ignorent l'exigence HTTPS de Secure, donc tout fonctionne. Sur une IP de LAN en http, la même configuration fait refuser le cookie en silence — d'où la moitié des « ça marche chez moi ».

Ce qu'il reste à faire après

Cinq attributs et une route, c'est une heure de travail, et ça ne remplace ni la rotation côté serveur, ni la CSP, ni un contrôle d'accès par ligne. Sur une app codée vite, ces trois couches sautent souvent ensemble : c'est ce que Rustine regarde avant une mise en prod — les cookies réellement posés, les en-têtes réellement envoyés, les routes qui répondent à un inconnu.

Ton app est-elle touchée ? Regarde en une minute.Scanner mon site