Un endpoint de login vous renvoie un JWT, et vous avez trois endroits où le stocker : un cookie, localStorage, ou sessionStorage. Faites le mauvais choix et soit une faille XSS embarque le token avec elle, soit le token n’arrive jamais jusqu’à votre API. Les trois stockent des chaînes de caractères dans le navigateur. Ce qui change, c’est qui peut les lire, combien de temps elles survivent, et si le serveur les voit un jour.
Quelle est la différence entre localStorage, sessionStorage et les cookies ?
localStorage et sessionStorage sont la même API, l’interface Storage : setItem, getItem, removeItem, clear, des clés et des valeurs en chaînes de caractères. Ce qui diffère, c’est la durée de vie. localStorage survit à la fermeture du navigateur et au redémarrage de la machine, et reste là jusqu’à ce que quelque chose le vide. Safari fait exception : avec la protection contre le tracking activée, il supprime le stockage écrit par script pour un site avec lequel l’utilisateur n’a pas interagi depuis sept jours. sessionStorage appartient à un seul onglet et meurt quand cet onglet se ferme. Tapez la même URL dans un second onglet et vous obtenez un sessionStorage neuf et vide ; seul « Dupliquer l’onglet » le recopie.
Les cookies sont plus anciens et fonctionnent différemment. Le serveur en pose un avec un header de réponse Set-Cookie, et à partir de là le navigateur l’attache automatiquement à chaque requête correspondante, dans le header Cookie. Cela inclut les requêtes d’images et de feuilles de style, et les appels fetch same-origin que vous n’avez jamais configurés pour envoyer quoi que ce soit. localStorage et sessionStorage ne quittent jamais le navigateur, sauf si votre JavaScript les lit et les envoie quelque part.
Envoyé automatiquement contre jamais envoyé : cette seule propriété tranche la plupart des questions de « lequel choisir ».
Limites de stockage et persistance : le comparatif
| Cookies | localStorage | sessionStorage | |
|---|---|---|---|
| Envoyé au serveur | Oui, à chaque requête correspondante | Non | Non |
| Capacité | ~4 Ko par cookie | ~5-10 Mo par origine (selon le navigateur) | ~5-10 Mo par origine |
| Persistance | Définie par Expires/Max-Age ; cookie de session si omis | Jusqu’à suppression explicite | Jusqu’à la fermeture de l’onglet |
| Portée | Domaine + chemin (configurable) | Origine (schéma + hôte + port) | Origine, et seulement l’onglet courant |
| Lisible depuis JS | Oui, sauf si HttpOnly | Toujours | Toujours |
| API | Header texte, ou document.cookie | Storage (synchrone) | Storage (synchrone) |
Cet écart de capacité explique pourquoi les paniers d’achat et les gros objets de préférences ne vont pas dans les cookies. 4 Ko, ça part vite une fois que vous stockez du JSON, et chaque octet est réexpédié à chaque requête, que l’endpoint en ait besoin ou non. Une page qui charge 40 assets statiques depuis le même domaine, avec un cookie de 3 Ko attaché, envoie ce cookie 40 fois.
Comment lire et écrire chacun en JavaScript
localStorage et sessionStorage s’utilisent avec les mêmes appels :
| |
Les deux ne stockent que des chaînes de caractères. setItem("user", {name: "Dan"}) stocke littéralement la chaîne "[object Object]". Sérialisez d’abord :
| |
Depuis JavaScript, un cookie passe par document.cookie :
| |
On dirait une affectation qui écrase tout. Ce n’en est pas une. Chaque affectation pose ou met à jour un seul cookie, et relire document.cookie vous donne tous les cookies de la page sous la forme d’une seule chaîne jointe par ; . Vous découpez vous-même sur ; puis sur le premier =, ou vous utilisez une petite bibliothèque qui le fait pour vous.
La plupart des cookies sont plutôt posés par le serveur, dans les headers de réponse. C’est aussi la seule façon d’obtenir les protections qui comptent pour tout ce qui est sensible :
| |
Cet appel produit un header de réponse du type :
| |
HttpOnly, Secure et SameSite : ce qui rend un cookie sûr
HttpOnlyretire complètement le cookie dedocument.cookie. Le JavaScript de la page ne peut pas le lire, et ça inclut le script injecté par un attaquant. Pour un token de session, c’est la raison principale de préférer un cookie àlocalStorage: une faille XSS qui exécute du JS arbitraire ne peut toujours pas exfiltrer un cookieHttpOnly.Secureempêche le navigateur d’envoyer le cookie en HTTP non chiffré ; il ne part qu’en HTTPS. Activez-le partout en production. Voir Qu’est-ce que TLS et Comment Fonctionne le Chiffrement HTTPS pour savoir ce que vaut cette protection.SameSitecontrôle si le cookie part sur des requêtes cross-site.Strictne l’envoie que sur les requêtes same-site.Laxl’envoie encore sur une navigation GET de premier niveau, donc suivre un lien depuis un email vous connecte toujours, mais le retient sur les POST cross-site et les appelsfetch. Ça bloque la plupart des attaques CSRF.Nonel’envoie partout et exigeSecure, sinon le navigateur jette le cookie purement et simplement. Chrome et Edge traitent un cookie sansSameSitecommeLax; Firefox et Safari ne le font pas, donc fixez-le explicitement.
localStorage et sessionStorage n’ont l’équivalent d’aucune de ces protections. Tout ce que vous y mettez est lisible par n’importe quel script qui tourne sur votre origine : votre propre code, une dépendance npm compromise, la charge utile d’un attaquant après une faille XSS. Les cookies ne sont pas plus sûrs par nature. Ils ont un mode optionnel que JavaScript ne peut pas toucher, et localStorage n’en a pas. C’est tout l’argument contre le fait de garder un token d’authentification dans localStorage.
Pourquoi les cookies ne partent pas avec un fetch cross-origin
Une page sur app.example.com appelle api.example.com avec fetch. Le cookie posé par api.example.com reste chez lui, même avec le bon domaine configuré sur le cookie, parce que fetch n’envoie les credentials qu’à la même origine par défaut. Il faut l’activer explicitement :
| |
Le serveur doit coopérer aussi. Dès que les credentials entrent en jeu, il ne peut plus répondre avec le wildcard Access-Control-Allow-Origin: *. Il doit renvoyer l’origine précise et ajouter Access-Control-Allow-Credentials: true. C’est la négociation CORS couverte dans Qu’est-ce que CORS et Comment Résoudre les Erreurs CORS, et c’est là que vivent la plupart des bugs du genre « mon cookie n’arrive pas ».
Comment synchroniser localStorage entre les onglets avec l’événement storage
Quand localStorage change, le navigateur déclenche un événement storage dans chaque autre onglet de la même origine, pas dans l’onglet qui a fait le changement. Ça permet de déconnecter un utilisateur partout dès qu’un onglet appelle localStorage.removeItem("token") :
| |
sessionStorage ne déclenche jamais cet événement entre onglets. Aucun autre onglet ne peut le voir.
Quand ne pas utiliser les cookies, localStorage ou sessionStorage
Les cookies sont le mauvais choix pour tout ce qui dépasse quelques kilooctets, et pour les données que le serveur ne lit jamais, comme un thème d’interface ou un commentaire à moitié écrit. Vous le réexpédieriez à chaque requête pour rien.
localStorage est le mauvais choix pour tout ce qui doit survivre à une faille XSS : tokens de session, clés d’API, tout ce que vous détesteriez voir dans un rapport d’incident qui dit « l’attaquant a lancé console.log(localStorage) ». C’est aussi le mauvais choix pour des données sur lesquelles le serveur agit. Ça ne quitte jamais le navigateur sauf si votre code l’envoie, et une fois que vous avez écrit ce code, vous avez reconstruit un cookie sans HttpOnly, Secure ni SameSite.
sessionStorage est le mauvais choix pour tout ce qui doit être partagé entre onglets, ou qu’on s’attend à retrouver après que l’utilisateur a fermé puis réouvert le navigateur. Il correspond à un seul type de problème : un état qui appartient à cet onglet, pour cette visite. L’étape en cours d’un checkout multi-pages en est le cas classique, puisqu’elle ne doit pas fuiter dans un second onglet que l’utilisateur a ouvert pour comparer les prix.
Au-delà de quelques mégaoctets, ou dès que vous avez besoin de requêtes plutôt que de simples lookups par clé, aucun des trois ne convient. Passez à IndexedDB avant d’essayer de faire rentrer quelques milliers d’enregistrements JSON dans localStorage et de taper le quota.
Que choisir pour les tokens, le panier et les préférences
| Donnée | Mécanisme | Pourquoi |
|---|---|---|
| Token de session, état d’authentification | Cookie HttpOnly + Secure | Le serveur en a besoin ; JS ne doit pas pouvoir le lire |
| Token CSRF | Cookie (double-submit) ou session serveur | Doit faire l’aller-retour avec la requête qu’il protège |
| Thème, langue, préférence d’interface | localStorage | Petit, pas sensible, doit persister |
| Brouillon de formulaire multi-étapes, état d’un wizard | sessionStorage | Par onglet, jetable, pas besoin d’aller-retour serveur |
| Panier (non authentifié) | localStorage, ou côté serveur lié à un cookie de session | Dépend du besoin de survivre à un navigateur vidé |
| Données structurées volumineuses, cache offline | IndexedDB | Nécessite capacité et requêtes qu’aucun des trois ne fournit |
Si votre serveur doit s’y fier, mettez-le dans un cookie HttpOnly, Secure, avec un SameSite explicite. Réservez localStorage aux données que vous accepteriez de confier à n’importe quel script de votre page.