Pièges de sécurité JWT et session, et comment les éviter
Les erreurs JWT et de session qui mènent au vol de compte : confusion d’algorithme, secrets faibles, claims non vérifiés, stockage, rotation, déconnexion.
· 7 min de lecture · Lina Source LLC
Les JSON Web Tokens sont un format raisonnable assorti d’une longue liste d’arêtes vives. Le jeton lui-même est rarement le problème. Les bugs vivent dans la façon dont il est vérifié, dans l’endroit où il est stocké, dans sa durée de vie et dans ce qui se passe quand un utilisateur se déconnecte. Chacune de ces erreurs a la même issue : quelqu’un détient un jeton qu’il ne devrait pas avoir, et votre serveur l’accepte.
Les deux faiblesses qui reviennent le plus sont CWE-347, vérification incorrecte d’une signature cryptographique, et CWE-613, expiration de session insuffisante. Les exemples ci-dessous utilisent jose, une bibliothèque JavaScript largement employée pour les JWT, qui fonctionne dans Node.js, les runtimes edge et les navigateurs.
Décoder n’est pas vérifier
La forme la plus directe de CWE-347 consiste à lire les claims d’un jeton sans contrôler sa signature. Toute bibliothèque JWT propose une fonction de décodage pour le débogage, et elle apparaît dans les middlewares d’authentification plus souvent qu’elle ne le devrait. Un jeton décodé n’est que du base64 que n’importe qui peut écrire. Dans les bases de code JavaScript, le motif se cache souvent dans un petit helper qui découpe le jeton sur les points et applique JSON.parse à la partie du milieu. Cela fonctionne dans tous les tests, parce que les jetons de test sont valides, et cela accepte tous les jetons forgés en production.
import { decodeJwt, jwtVerify } from 'jose';
// Vulnérable : n'importe qui peut forger un jeton avec sub défini sur un ID utilisateur quelconque
const claims = decodeJwt(token);
const userId = claims.sub;
// Correct : la signature, l'algorithme et les claims sont vérifiés d'abord
const { payload } = await jwtVerify(token, key, { algorithms: ['HS256'] });
const verifiedUserId = payload.sub;alg none et confusion d’algorithme
L’en-tête du JWT indique quel algorithme a signé le jeton, et c’est l’attaquant qui contrôle cet en-tête. Deux attaques classiques découlent du fait de lui faire confiance. La première est alg réglé sur none : un jeton non signé que certaines bibliothèques anciennes acceptaient comme valide. La seconde est la confusion d’algorithme : un serveur qui attend du RS256 mais laisse l’en-tête choisir l’algorithme peut se voir remettre un jeton HS256 signé avec la clé publique du serveur utilisée comme secret HMAC. La clé publique est publique, donc l’attaquant peut signer n’importe quoi.
Les bibliothèques modernes se défendent contre les deux, et jose rejette les jetons non sécurisés dans jwtVerify et vérifie que le type de clé correspond à l’algorithme. Ne vous fiez pas aux seuls réglages par défaut. Fixez explicitement la liste des algorithmes à chaque appel de vérification, pour qu’un futur refactoring ou un changement de bibliothèque ne puisse pas l’élargir en silence. Si vous acceptez plusieurs algorithmes, par exemple pendant une migration de clés, listez-les explicitement et utilisez une clé distincte pour chacun. Lorsque vous effectuez une rotation de clés, placez un kid dans l’en-tête et sélectionnez la clé par kid depuis votre propre liste, jamais depuis une URL nommée dans l’en-tête du jeton.
Des secrets de signature faibles
Un jeton HS256 est signé avec un secret partagé. Si ce secret est court ou devinable, par exemple « secret », le nom de l’application ou une valeur copiée d’un tutoriel, un attaquant qui possède un seul jeton valide peut le casser hors ligne par force brute avec des outils courants, puis signer des jetons pour n’importe quel utilisateur. Le devinage hors ligne n’a aucune limite de débit.
- Utilisez au moins 32 octets aléatoires pour HS256, générés par exemple avec openssl rand -base64 32.
- Chargez le secret depuis l’environnement ou un gestionnaire de secrets, et échouez au démarrage s’il est absent ou trop court.
- Ne le committez jamais, et effectuez une rotation s’il a un jour été committé.
- Si plusieurs services doivent vérifier les jetons mais qu’un seul doit les émettre, utilisez un algorithme asymétrique comme RS256 ou EdDSA pour que les vérificateurs ne détiennent que la clé publique.
Validez exp, aud et iss
Une signature valide prouve seulement qui a émis le jeton. Ce sont les claims qui décident s’il vous est destiné et s’il est toujours d’actualité. Un jeton sans expiration est valide pour toujours. Un jeton émis pour votre API mobile ne devrait pas être accepté par votre service d’administration simplement parce que les deux font confiance au même fournisseur d’identité. C’est à cela que servent aud et iss.
import { SignJWT, jwtVerify } from 'jose';
const rawSecret = process.env.JWT_SECRET;
if (!rawSecret || rawSecret.length < 32) {
throw new Error('JWT_SECRET must be set and at least 32 characters');
}
const secret = new TextEncoder().encode(rawSecret);
const ISSUER = 'https://api.example.com';
const AUDIENCE = 'https://app.example.com';
export function signAccessToken(userId: string, tokenVersion: number) {
return new SignJWT({ tv: tokenVersion })
.setProtectedHeader({ alg: 'HS256' })
.setSubject(userId)
.setIssuer(ISSUER)
.setAudience(AUDIENCE)
.setIssuedAt()
.setExpirationTime('10m')
.sign(secret);
}
export async function verifyAccessToken(token: string) {
const { payload } = await jwtVerify(token, secret, {
algorithms: ['HS256'],
issuer: ISSUER,
audience: AUDIENCE,
requiredClaims: ['exp', 'sub'],
});
return payload;
}jose vérifie exp dès qu’il est présent, mais un jeton dépourvu d’exp passerait sans cela. L’option requiredClaims comble cette lacune. Pour les jetons issus d’un fournisseur d’identité externe, vérifiez-les contre son jeu de clés publié avec createRemoteJWKSet, et fixez tout de même les algorithmes, l’émetteur et l’audience.
localStorage ou cookies httpOnly
Stocker un jeton dans localStorage le rend lisible par n’importe quel script de la page. Un seul bug XSS, ou un seul script tiers compromis, et le jeton peut être envoyé à un attaquant puis utilisé depuis n’importe où jusqu’à son expiration.
Un cookie httpOnly ne peut pas être lu par JavaScript. Le XSS reste grave, car un script injecté peut effectuer des requêtes au nom de l’utilisateur tant que la page est ouverte, mais il ne peut pas voler un identifiant à longue durée de vie et le rejouer plus tard. Pour les applications navigateur qui parlent à leur propre backend, les cookies sont le meilleur choix par défaut. Pour une application monopage qui appelle une API séparée, conserver le jeton d’accès uniquement en mémoire et le jeton de rafraîchissement dans un cookie httpOnly est un compromis raisonnable.
// Express : cookie de session avec des attributs sûrs
res.cookie('__Host-session', accessToken, {
httpOnly: true, // illisible depuis JavaScript
secure: true, // HTTPS uniquement ; exigé par le préfixe __Host-
sameSite: 'lax', // non envoyé sur les POST cross-site
path: '/', // exigé par le préfixe __Host-
maxAge: 10 * 60 * 1000, // millisecondes, correspond à la durée de vie du jeton
});Le préfixe __Host- indique au navigateur de rejeter le cookie s’il n’est pas Secure, si son path n’est pas /, ou s’il possède un attribut Domain, ce qui empêche un sous-domaine compromis de l’écraser.
SameSite et ce qu’il ne couvre pas
SameSite=Lax bloque le cookie sur les requêtes POST cross-site et sur les chargements de sous-ressources, ce qui élimine l’essentiel du CSRF classique. Il envoie toujours le cookie sur les navigations GET de premier niveau, donc tout endpoint GET qui modifie l’état reste exposé. Strict bloque aussi ces cas, mais déconnecte les utilisateurs qui suivent un lien vers votre application depuis un e-mail ou un autre site. None désactive la protection et exige Secure.
Lax, assorti d’une règle voulant que les requêtes GET ne modifient jamais l’état, constitue une base saine. Pour les mutations sensibles, ajoutez une vérification de l’en-tête Origin ou un jeton CSRF. N’oubliez pas que SameSite considère tous les sous-domaines de votre domaine enregistrable comme same-site : un sous-domaine vulnérable peut donc toujours forger des requêtes.
Rotation, révocation et vraie déconnexion
Un JWT sans état ne peut pas être révoqué avant son expiration ; c’est le compromis qui permet de ne pas interroger une base de données à chaque requête. CWE-613 décrit ce qui arrive lorsque ce compromis est ignoré : des déconnexions, des changements de mot de passe et des suspensions de compte qui ne mettent pas réellement fin à l’accès. Supprimer un compte, changer un rôle et retirer quelqu’un d’une équipe sont aussi des événements de révocation, et chacun devrait prendre effet à la requête suivante, pas à la prochaine expiration de jeton.
- Gardez les jetons d’accès à courte durée de vie, de l’ordre de quelques minutes, pas de quelques jours.
- Stockez les jetons de rafraîchissement côté serveur, hachés, et effectuez leur rotation à chaque usage.
- Si un jeton de rafraîchissement déjà remplacé est réutilisé, traitez cela comme un vol et révoquez toute la famille de jetons.
- Ajoutez une version de jeton dans la ligne utilisateur et dans le jeton ; incrémentez-la à la déconnexion globale, au changement de mot de passe ou à la suspension.
- Régénérez l’identifiant de session à la connexion pour empêcher la fixation de session.
export async function requireUser(token: string) {
const payload = await verifyAccessToken(token);
const user = await db.user.findUnique({
where: { id: payload.sub },
select: { id: true, tokenVersion: true, disabled: true },
});
// Une version incrémentée ou un compte désactivé met fin à l'accès immédiatement
if (!user || user.disabled || user.tokenVersion !== payload.tv) {
throw new Error('Session revoked');
}
return user;
}Cette lecture réintroduit un accès à la base de données par requête, ce qui est le coût honnête de la révocation. Beaucoup d’applications sont plus simples et plus sûres avec un identifiant de session opaque dans un cookie et une table de sessions. Se déconnecter revient alors à supprimer une ligne. Utilisez les JWT là où leurs propriétés aident réellement, par exemple pour des jetons de courte durée entre services, et non parce qu’ils sont l’option par défaut d’un tutoriel.
Quel que soit votre choix, la déconnexion doit avoir lieu sur le serveur. Effacer le cookie ou supprimer le jeton dans le navigateur ne retire que la copie de l’utilisateur ; une copie volée continue de fonctionner jusqu’à ce que le serveur la refuse. À la déconnexion, supprimez la session ou le jeton de rafraîchissement côté serveur, effacez le cookie en utilisant les mêmes nom, path et attributs que lors de sa création, et incrémentez la version de jeton si l’utilisateur a choisi de se déconnecter partout. Une réinitialisation de mot de passe doit également mettre fin à toutes les autres sessions.
Une courte checklist de revue
- Cherchez les appels de décodage dans les chemins d’authentification.
- Vérifiez que chaque appel de vérification fixe les algorithmes, l’émetteur et l’audience, et exige exp.
- Contrôlez comment le secret de signature est généré, chargé et validé au démarrage.
- Repérez où les jetons sont stockés dans le navigateur.
- Testez que la déconnexion, le changement de mot de passe et la suspension de compte mettent fin aux sessions existantes.
Ces vérifications consistent surtout à lire les chemins de code de bout en bout, ce qui est aussi la façon dont CodeAuditAgent aborde un audit : les résultats arrivent avec la ligne citée, la CWE, un scénario d’exploitation et un correctif, si bien qu’un contrôle d’audience manquant peut être confirmé et corrigé en quelques minutes.