Les erreurs JWT qui deviennent des violations
Dernière mise à jour:
Un JSON Web Token est une déclaration signée, pas une enveloppe scellée. Ce sont trois segments encodés en Base64url reliés par des points : un en-tête qui nomme l’algorithme de signature, une payload de claims concernant un utilisateur ou une session, et une signature portant sur les deux premiers. La signature prouve que le jeton a été émis par celui qui détient la clé et n’a pas été modifié depuis. Elle ne prouve rien quant au secret : quiconque détient le jeton peut décoder la payload et y lire chaque claim, et c’est pourquoi le décoder demande un coller, pas un mot de passe. Quand les JWT échouent en production, ce n’est presque jamais la cryptographie qui a cédé. Ce qui a cédé, c’est une vérification que quelqu’un a sautée : l’algorithme accepté sur la foi du jeton lui-même, l’expiration que personne n’a validée, le secret partagé qu’un humain aurait pu taper de mémoire. Collez n’importe quel jeton dans le décodeur JWT, et son en-tête et ses claims s’affichent au grand jour. Cet article parcourt les erreurs d’ingénierie qui transforment ces vérifications sautées en violations, et la règle défensive qui ferme chacune d’elles.
Signé ne veut pas dire secret
La payload est du Base64url — un encodage, de la même famille que le Base64 ordinaire — et non un chiffrement. Tout ce qu’elle contient est lisible par quiconque le jeton traverse : adresses e-mail, rôles, identifiants de locataire, numéros de compte internes, et parfois bien pire, comme des indicateurs de réinitialisation de mot de passe ou des fragments de données personnelles qu’un développeur a ajoutés pour déboguer et jamais retirés. Les jetons voyagent aussi plus loin que les développeurs ne l’imaginent : ils dorment dans le stockage du navigateur, traversent les journaux de proxies et d’applications, sont copiés dans les rapports de plantage et collés dans les tickets de bugs et les fils de discussion. La règle de travail en découle directement : ne mettez jamais dans un jeton un claim que vous ne seriez pas à l’aise de voir imprimé dans un fichier journal, car tôt ou tard il y apparaîtra.
Le malentendu est compréhensible : un jeton compact ressemble à du texte chiffré, rien que des caractères imprononçables et des points. Mais la signature rend seulement le jeton infalsifiable en silence : modifiez un claim et la vérification échoue. La confidentialité est une construction différente de la même famille de normes (les jetons chiffrés, JWE), et un jeton d’accès ordinaire en est rarement une. Concevez donc la payload comme un texte public portant une signature : identifiants et faits d’autorisation seulement, les données sensibles récupérées côté serveur par identifiant, et des durées de vie courtes pour que ce qui fuit malgré tout expire vite.
Laisser le jeton choisir son propre algorithme
L’en-tête déclare un alg, et la plus vieille défaillance du livre des JWT est un vérificateur qui le croit. L’une des valeurs déclarées est none : un jeton non signé, sans aucune signature. Une bibliothèque ou une vérification maison qui obéit à l’algorithme déclaré par le jeton lui-même peut recevoir un jeton à signature vide et l’accepter comme authentique. Le RFC des bonnes pratiques est sans détour à ce sujet : les jetons non signés ne sont acceptables que là où le canal lui-même garantit déjà l’intégrité, ce que n’est pas un jeton bearer arrivant par l’internet public. Le même défaut — faire confiance à l’en-tête — produit la confusion d’algorithme. Un système qui émet des jetons RS256 détient une clé privée et publie la clé publique correspondante pour la vérification. Si l’on peut persuader le vérificateur de traiter le jeton comme du HS256, le matériel de vérification devient la clé publique — publique par conception, connue de tous — utilisée comme secret symétrique, et les signatures que n’importe qui peut produire commencent à passer la vérification. Dans aucune des deux défaillances une clé n’est volée ; le vérificateur a simplement suivi des instructions écrites par l’expéditeur.
L’en-tête porte un second risque, plus discret : les pointeurs de clé. Des paramètres comme kid (un identifiant de clé), jku et x5u (des URL où vivent une clé ou un certificat) indiquent au vérificateur où trouver la clé de vérification — et ils arrivent à l’intérieur du jeton, sous l’influence de l’attaquant. Si une valeur kid s’écoule sans contrôle strict dans un chemin de fichier, une requête de base de données ou une récupération de clé, la recherche elle-même devient la surface d’attaque. Les règles défensives ont toutes la même forme : épinglez l’algorithme attendu dans la configuration côté serveur et rejetez tout le reste ; résolvez les clés depuis votre propre registre, avec des identifiants confrontés à une liste d’autorisation ; ne récupérez jamais de matériel de clé depuis une URL fournie par le jeton ; et traitez chaque champ d’en-tête comme une entrée non fiable, parce qu’il l’est.
La signature est passée ; les claims n’ont jamais été vérifiées
Vérifier une signature répond à une seule question : ce jeton a-t-il été émis par le détenteur de la clé et laissé intact ? Cela ne dit rien sur la question de savoir si le jeton doit être honoré ici, maintenant. C’est le travail des claims, et la validation des claims est l’endroit où les systèmes de production sautent des étapes en silence. D’abord exp : un vérificateur qui ne contrôle jamais l’expiration transforme chaque jeton en titre d’accès permanent, si bien qu’un jeton divulgué dans un journal il y a trois ans authentifie encore aujourd’hui. Ensuite iss : n’acceptez les jetons que des émetteurs que vous avez configurés, pas de quiconque partage un algorithme. Et aud, l’omission la plus coûteuse : les organisations font souvent tourner plusieurs services avec une seule clé de signature, et le jeton d’un service de faible valeur — une API gratuite, un outil voisin de la préproduction — devient alors un jeton correctement signé qu’un service de grande valeur accepte, parce que la signature est authentique et que personne n’a demandé à quelle audience le jeton était destiné.
Rien de tout cela n’est exotique. C’est une liste de contrôle, et le mode de défaillance est que les bibliothèques vérifient la signature avec enthousiasme tout en laissant les contrôles de claims optionnels, désactivés ou à la charge de l’appelant. La posture défensive consiste à exiger exp, iss et aud à chaque vérification, à échouer fermé devant toute absence ou incohérence, et à tolérer les dérives d’horloge en minutes, pas en heures : la tolérance existe pour les horloges imparfaites, et étirée assez loin elle ressuscite en silence les jetons expirés. Testez ensuite les cas négatifs comme vous testeriez un formulaire de connexion : un jeton expiré, un jeton de mauvaise audience et un jeton d’un émetteur inconnu doivent tous être rejetés, et ces rejets méritent d’être journalisés comme le signal de sécurité qu’ils sont.
Les secrets qu’un humain retient sont des secrets qu’un attaquant peut tester
HS256 est symétrique : un seul secret partagé signe et vérifie. Dans les bases de code réelles, ce secret est trop souvent un mot ou une courte phrase — un nom d’entreprise, une valeur par défaut de framework copiée d’un tutoriel, quelque chose qu’un développeur pourrait taper de mémoire. Voici le principe qui rend cela fatal, et c’est une propriété de la conception plutôt que d’un quelconque outil : un jeton capturé transporte tout ce qu’il faut pour tester un secret deviné hors ligne. Le message est connu, la signature est connue, donc les suppositions peuvent être vérifiées localement, à pleine vitesse, sans que le serveur voie jamais une tentative ni qu’aucune limite de débit ne s’applique. La sécurité de tout le schéma se réduit donc à l’entropie de cette unique chaîne — et les chaînes choisies par des humains en ont très peu.
Les remèdes n’ont rien de glamour. Générez les secrets de signature à la machine, à une longueur correcte — au moins 256 bits d’entropie pour HMAC — et conservez-les comme les identifiants de production qu’ils sont. Rendez-les uniques par environnement, pour que le secret d’une configuration de préproduction divulguée n’achète rien en production, et ne laissez jamais une valeur par défaut de développement survivre à un déploiement réel. Là où de nombreux services vérifient des jetons, préférez les algorithmes asymétriques comme RS256 ou ES256 : les vérificateurs ne détiennent alors que des clés publiques, si bien qu’un vérificateur compromis ne divulgue rien qui permette de frapper des jetons, et la clé de signature reste à un seul endroit. Et prévoyez une histoire de rotation avant d’en avoir besoin, car avec un secret symétrique aucune alarme ne sonne quand il fuit : le premier signe est d’ordinaire un jeton que vous n’avez pas émis, accepté comme le vôtre.
L’endroit où le navigateur garde le jeton décide qui peut le lire
Côté client, le choix du stockage est une décision de sécurité, qu’elle ait été prise délibérément ou non. Un jeton dans localStorage est lisible par chaque script qui s’exécute sur la page : votre propre code, chaque balise tierce et extrait d’analytique que vous avez admis — et toute charge de cross-site scripting qui vient à s’exécuter, venue de n’importe quel champ de commentaire non échappé dans n’importe quel recoin de l’application. Un jeton bearer est un titre copiable : quiconque le lit peut le présenter depuis n’importe où et être cet utilisateur jusqu’à son expiration. Les jetons longue durée dans un stockage lisible par les scripts valent donc exactement ce que vaut votre pire faille XSS, et la plupart des applications finissent par en avoir une.
L’alternative habituelle, un cookie HttpOnly, supprime entièrement l’accès des scripts — une charge XSS ne peut plus lire ni exfiltrer le jeton —, mais le navigateur joint le cookie automatiquement, ce qui fait de la falsification de requête intersite la menace à traiter, avec les attributs SameSite et des jetons CSRF. Ne garder les jetons qu’en mémoire esquive les deux problèmes et perd la session à chaque rechargement. Il n’existe pas de réponse universellement correcte, et prétendre le contraire est la façon dont les équipes finissent avec le pire de chaque option. Ce qui transforme le stockage en violation, c’est la combinaison que personne n’a choisie à dessein : stockage lisible par les scripts, longues durées de vie, portées larges et aucune rotation des jetons de rafraîchissement pour réduire la valeur d’une copie. Choisissez le compromis en connaissance de cause, gardez les jetons d’accès à courte durée dans tous les cas, et écrivez le raisonnement là où le prochain développeur le trouvera.