JWT ou cookies de session : différences et cas d'usage
Un cookie de session est une clé vers un état conservé sur le serveur, alors qu'un JWT transporte un état signé jusqu'au serveur. Cette seule différence explique tous les compromis.
On présente souvent la question comme un duel entre JWT et cookies, mais ces deux notions ne sont pas de même nature. Un cookie est un moyen d'envoyer des données avec chaque requête. Un JWT est un format de données. Le vrai choix se fait entre une session côté serveur, où le navigateur détient un identifiant aléatoire et où le serveur cherche à qui il appartient, et un token autonome, où le client détient des claims signés que le serveur peut vérifier sans rien consulter. Une fois ce point clarifié, les questions de révocation, de stockage et de CSRF trouvent naturellement leur place.
Comment fonctionne une session côté serveur
Après une connexion réussie, le serveur crée un enregistrement de session (ID de l'utilisateur, rôles, expiration) en mémoire, dans une base de données ou dans un magasin comme Redis, puis envoie au navigateur un identifiant impossible à deviner dans un cookie. Le navigateur joint ce cookie à chaque requête suivante vers ce site, et le serveur charge l'enregistrement à chaque fois. L'identifiant ne signifie rien en lui-même : 32 octets aléatoires s'encodent en 43 caractères.
Set-Cookie: __Host-session=p-sLYX7J3ziMKxq-dadVMuQJNSVBHM5hrw7MJPihGQ0; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=86400
Avec le préfixe __Host-, les navigateurs refusent le cookie s'il n'est pas Secure, s'il n'a pas Path=/ ou s'il définit un Domain : un sous-domaine ne peut donc pas l'écraser.
Comment fonctionne un JWT
Un JSON Web Token (RFC 7519) se compose de trois parties encodées en Base64URL et séparées par des points : un en-tête qui nomme l'algorithme, une charge utile (payload) de claims comme sub et exp, et une signature calculée sur les deux premières parties. Le serveur vérifie la signature et l'expiration à l'aide d'une clé, puis fait confiance aux claims sans interroger de base de données. Le token est en général envoyé dans un en-tête Authorization: Bearer.
La charge utile est seulement encodée, pas chiffrée. Quiconque détient le token peut la lire, ce que vous pouvez constater en en collant un dans le décodeur JWT. Ne mettez jamais dans un JWT de secrets ni de données personnelles que vous ne montreriez pas à l'utilisateur. Des tokens chiffrés existent (JWE, RFC 7516), mais la plupart des JWT en circulation sont seulement signés. Pour comprendre pourquoi les parties utilisent - et _ au lieu de + et /, consultez Base64 vs Base64URL.
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzg0MTIiLCJpYXQiOjE3NjAwMDAwMDAsImV4cCI6MTc2MDAwMDkwMH0.08BFm85KQIp34VSetcibqwBVLpHKR3IZhI_IPNeTif4
// Reading the payload needs no key at all (browser):
const part = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
const bytes = Uint8Array.from(atob(part), c => c.charCodeAt(0));
JSON.parse(new TextDecoder().decode(bytes));
// { sub: 'user_8412', iat: 1760000000, exp: 1760000900 }
Les compromis côte à côte
| Session côté serveur | JWT | |
|---|---|---|
| Le client détient | Un identifiant aléatoire, sans signification | Des claims signés et lisibles |
| Le serveur a besoin de | Un magasin de sessions, partagé par tous les serveurs | Uniquement la clé qui vérifie les signatures |
| Déconnecter un utilisateur partout | Supprimer la session : effet immédiat | Impossible avant exp sans état supplémentaire |
| Changement de rôle ou de permission | S'applique dès la requête suivante | S'applique à l'émission d'un nouveau token |
| Transport habituel | Cookie, envoyé automatiquement | En-tête Authorization, ou un cookie |
| Taille par requête | ID de 43 caractères dans notre exemple | De 152 à 540 caractères dans nos exemples |
Révocation, expiration et refresh tokens
L'absence d'état côté serveur (stateless) est le principal argument en faveur des JWT, et aussi leur principale faiblesse. Un token valide le reste jusqu'à son expiration, même après que l'utilisateur s'est déconnecté, a changé son mot de passe ou a été banni. La réponse habituelle consiste à garder des access tokens à courte durée de vie, souvent quelques minutes plutôt que quelques heures, et à les associer à un refresh token (jeton de rafraîchissement) valable plus longtemps, que le client échange contre de nouveaux access tokens. Le refresh token est vérifié par rapport à un état côté serveur : le révoquer empêche donc l'émission de nouveaux access tokens.
L'OAuth 2.0 Security Best Current Practice (RFC 9700) décrit la rotation des refresh tokens : chaque rafraîchissement renvoie un nouveau refresh token et invalide l'ancien, ce qui permet de détecter la réutilisation d'un token volé. Si vous avez aussi besoin de révoquer instantanément les access tokens, vous finissez par tenir une liste de refus d'identifiants de tokens (jti) ou une version de token par utilisateur, c'est-à-dire de nouveau un état côté serveur. À ce stade, demandez-vous si une simple session ne serait pas plus simple.
Où stocker le token : cookies, localStorage et XSS
Tout ce qui se trouve dans localStorage ou sessionStorage peut être lu par n'importe quel script exécuté sur votre page. Une seule faille de cross-site scripting (XSS) ou un seul script tiers compromis suffit pour copier le token et l'utiliser depuis n'importe où jusqu'à son expiration.
Un cookie marqué HttpOnly est totalement illisible pour JavaScript : une faille XSS ne peut donc pas le voler. Elle peut en revanche envoyer des requêtes depuis la page ouverte par la victime, et le navigateur y joindra le cookie : HttpOnly limite les dégâts sans les empêcher. Prévenir les failles XSS reste indispensable dans tous les cas ; les cookies HttpOnly rendent simplement une attaque réussie moins utile à l'attaquant.
CSRF et SameSite
La commodité du cookie fait aussi son risque : le navigateur le joint automatiquement, y compris aux requêtes déclenchées par un autre site web. C'est ce qu'on appelle la falsification de requête intersite (CSRF). Les tokens envoyés dans un en-tête Authorization ne sont pas exposés au CSRF classique, car le navigateur n'ajoute pas de lui-même un bearer token : c'est votre propre code qui doit le faire.
SameSite=Strict: le cookie n'est jamais envoyé avec les requêtes parties d'un autre site, y compris quand on suit un lien vers le vôtre.SameSite=Lax: le cookie est envoyé quand l'utilisateur arrive sur votre site par un lien normal (un GET de premier niveau), mais pas avec les envois de formulaires intersites, les requêtes fetch ou les ressources intégrées. Chrome applique Lax aux cookies qui ne définissent pas l'attribut ; définissez-le explicitement plutôt que de compter là-dessus.SameSite=None: le cookie est envoyé partout, et cette valeur doit être combinée avecSecure(sinon Chrome la rejette). Elle ne sert que pour un vrai usage intersite, comme des widgets intégrés.
« Même site » (same site) désigne le même domaine enregistrable : app.example.com et forum.example.com comptent donc comme le même site. SameSite ne vous protège pas d'un sous-domaine compromis. Pour les requêtes qui modifient des données, conservez aussi un token CSRF ou vérifiez l'en-tête Origin.
La taille des tokens
Un identifiant de session reste minuscule, quelle que soit la quantité d'informations dont vous disposez sur l'utilisateur. Un JWT grossit avec chaque claim et selon l'algorithme de signature. Les tokens que nous avons générés pour ce guide faisaient 152 caractères en HS256 avec trois claims, 241 caractères après l'ajout d'un nom, d'une adresse e-mail et d'un rôle, et 540 caractères pour les mêmes claims signés en RS256 avec une clé de 2048 bits. La RFC 6265 impose seulement aux navigateurs d'accepter des cookies d'au moins 4 096 octets, nom et attributs compris : un JWT rempli de permissions peut donc dépasser la taille d'un cookie. Et ce poids est envoyé avec chaque requête.
Les erreurs qui ruinent la sécurité d'un JWT
- Faire confiance à l'en-tête
alg. Fixez les algorithmes que votre serveur accepte. Accepternone, ou laisser un token passer de RS256 à HS256 pour que la clé publique serve de secret HMAC, permet à un attaquant de forger des tokens. - Décoder au lieu de vérifier. Lire la charge utile ne prouve rien : vérifiez d'abord la signature avec une bibliothèque maintenue.
- Négliger la vérification des claims. Validez
exp,nbf,issetaud, pour qu'un token émis pour un service ne soit pas accepté par un autre.
La RFC 8725, JWT Best Current Practices, traite de ces erreurs et de bien d'autres.
Quand choisir l'un ou l'autre
| Situation | Le choix le plus adapté en général |
|---|---|
| Application web rendue côté serveur sur un seul domaine | Cookie de session |
| Single-page app servie depuis le même site que son API | Cookie de session, ou un backend-for-frontend qui garde les tokens sur le serveur |
| Application mobile native | Access token à courte durée de vie et refresh token dans le stockage sécurisé de la plateforme |
| Microservices qui vérifient les requêtes sans appeler un service d'authentification | Access tokens JWT signés avec une clé asymétrique, par exemple RS256 ou ES256 |
| Accès de tiers à votre API | Access tokens OAuth 2.0, qui sont souvent des JWT |
La solution hybride : un JWT dans un cookie HttpOnly
Beaucoup d'applications placent le JWT lui-même dans un cookie HttpOnly; Secure; SameSite. Les scripts ne peuvent pas le lire, et les serveurs peuvent toujours le vérifier sans consulter de session. Les compromis suivent eux aussi : le cookie est envoyé automatiquement, il faut donc de nouveau des défenses contre le CSRF ; la limite de 4 Ko des cookies s'applique ; et la révocation reste aussi difficile qu'avec n'importe quel autre JWT. C'est une architecture solide pour une application web adossée à des API stateless, à condition de la traiter comme un cookie pour le CSRF et comme un JWT pour la révocation.