Skip to content
Retour au blog
Sécurité

JWT « invalid signature » : toutes les causes et comment les corriger

Toutes les vraies causes d'une signature JWT invalide : octets de clé selon le langage, retour à la ligne dans .env, algorithme incohérent. Décodeur gratuit.

14 min de lecture

JWT « invalid signature » : toutes les causes et comment les corriger

Une erreur JWT invalid signature ne veut dire qu’une seule chose : la signature calculée par votre vérificateur n’est pas égale à celle transportée dans le token. Le message ne dit rien de plus. Il ne signifie pas que le token a expiré, ni que l’utilisateur manque de droits. Votre bibliothèque JWT n’est pas cassée non plus. Ce qui diffère entre le côté qui signe et le côté qui contrôle, ce sont les octets qui entrent dans le HMAC, ou la clé publique passée à l’appel de vérification.

Neuf fois sur dix, le coupable est le matériel de clé, pas le token. Servez-vous de cet arbre pour choisir par où commencer :

Quel algorithme figure dans le header ?
├─ HS256 / HS384 / HS512  → presque toujours un problème de secret
│    ├─ signataire et vérificateur dans deux langages différents ? → section 3
│    └─ même langage, ça marche en local et casse en prod ?        → section 4
└─ RS256 / ES256 / PS256  → presque toujours un format de clé, ou la mauvaise clé
     └─ → section 7

Le token est passé par une gateway, un proxy, un copier-coller ?      → section 6
L'erreur n'apparaît qu'au bout de quelques heures, ou sur un seul hôte ? → section 8

Chaque section ci-dessous se termine par quelque chose que vous pouvez exécuter. Pour le geste le plus rapide, collez le token dans le décodeur JWT et lisez le champ alg : la moitié des branches ci-dessus disparaît dès que vous le connaissez.

1. Ce que « invalid signature » veut dire exactement

Les bibliothèques n’affichent pas toutes le même texte pour la même panne. Repérez la vôtre dans cette liste, histoire de vérifier que vous êtes au bon endroit :

  • Node jsonwebtoken : JsonWebTokenError: invalid signature
  • Python PyJWT : InvalidSignatureError: Signature verification failed
  • Java jjwt : SignatureException: JWT signature does not match locally computed signature. JWT validity cannot be asserted and should not be trusted.

Les trois se déclenchent au même instant, dans le même chemin de code. La bibliothèque prend les deux premiers segments de votre token, recalcule la signature avec la clé que vous lui avez donnée, puis compare le résultat octet par octet au troisième segment. Si les deux diffèrent, elle lève une exception.

La comparaison est exacte, et elle ne dit rien sur l’ampleur de l’écart entre les deux valeurs. Un secret qui diffère d’un seul octet et une clé complètement fausse produisent exactement le même message d’erreur. D’où la méthode retenue dans la suite : réduire l’espace des entrées, plutôt que relire l’erreur plus attentivement.

Au moment où cette erreur se déclenche, la validation des claims n’a pas encore eu lieu. Elle arrive après la vérification de signature : exp, nbf, aud et iss n’ont donc pas encore été regardés. Si la vérification de signature de votre JWT a échoué, le contenu du token n’entre pas dans le diagnostic, même s’il reste lisible, un JWT étant encodé et non chiffré. Décoder le header et le payload ne demande aucune clé ; voir comment décoder un JWT pour la visite segment par segment.

Deux champs du header décident de la suite : alg vous dit si vous chassez un secret partagé ou une paire de clés, et kid vous dit quelle clé le signataire croyait utiliser.

2. La signature porte sur la chaîne encodée, pas sur votre objet

Tout le reste dépend de ce modèle mental. La RFC 7515, la spécification JSON Web Signature, définit le JWS Signing Input comme la chaîne ASCII :

BASE64URL(UTF8(JWS Protected Header)) || '.' || BASE64URL(JWS Payload)

Le HMAC est calculé sur cette chaîne. Pas sur votre map claims, ni sur le moindre objet que votre langage considère comme une donnée structurée. Voici le signing input utilisé tout au long de cet article, tiré du payload d’exemple standard :

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ

La conséquence piège des équipes en permanence : toute couche qui décode le payload puis le ré-encode détruit la signature. La sérialisation JSON n’est pas canonique. L’ordre des clés change dès qu’une map fait l’aller-retour dans la plupart des langages. Des espaces apparaissent ou disparaissent. Les caractères non-ASCII sont échappés en \uXXXX par un sérialiseur et émis littéralement par un autre. Les nombres sont reformatés, si bien que 1516239022 peut revenir sous la forme 1516239022.0. Chacun de ces cas produit une chaîne base64url différente, donc un signing input différent, donc une signature différente.

Des déclencheurs que nous avons rencontrés :

  • Une API gateway qui parse le JWT pour l’enrichir d’un identifiant de tenant, puis ré-émet le token.
  • Un middleware de logs ou de tracing qui « normalise » les en-têtes et réécrit la valeur Authorization.
  • Un développeur qui a mis un token en forme pour le lire, puis a recollé la version embellie.

Si un composant situé entre votre signataire et votre vérificateur peut réécrire le token, c’est le premier suspect. En transit, un token est une chaîne opaque ; les seules opérations sûres sont stocker, copier et comparer.

3. Même secret, octets différents

Derrière les rapports de bug du type « le secret est rigoureusement identique, j’ai fait le diff », il y a presque toujours ceci : HMAC ne consomme pas une chaîne, il consomme des octets. Vos fichiers de configuration et votre gestionnaire de secrets, eux, stockent des chaînes. Quelque chose doit convertir l’un en l’autre, et cette conversion n’est pas normalisée d’une bibliothèque JWT à l’autre. Deux services peuvent détenir des secrets identiques caractère par caractère et calculer malgré tout des signatures différentes.

En voici la preuve, calculée en local sur le signing input de la section 2. La chaîne du secret fait 36 caractères :

c2VjcmV0LWtleS0xMjM0NTY3ODkwYWJjZGVm
Interprétation des octetsOctetsCe que la clé est réellementSignature HS256 obtenue
Traitée comme du texte UTF-836les 36 caractères visibles eux-mêmestUQobLFxHSIQqURPqGT59pkBnqQ95sZ0-JC1hE_4Zak
Décodée en base64 d’abord27secret-key-1234567890abcdef53ISDuciq-ov8YF1Ezwy6zo6KUO-1tpwz2oW7gVPuIM

Même chaîne de secret, même algorithme, même payload, et deux signatures qui n’ont rien en commun. Le côté qui s’est « trompé » remonte invalid signature, et vous aurez beau comparer les fichiers de configuration, vous ne trouverez rien : ils sont identiques.

Le token complet pour la lecture UTF-8, si vous voulez reproduire l’expérience :

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.tUQobLFxHSIQqURPqGT59pkBnqQ95sZ0-JC1hE_4Zak

Collez-le dans le décodeur JWT avec le secret ci-dessus : la signature est valide. Décodez d’abord le secret en base64 : elle ne l’est plus.

Comment chaque bibliothèque transforme une chaîne en octets de clé

Tenez-vous-en à ce qui est documenté. Le tableau ci-dessous est volontairement restreint, et la dernière colonne compte plus que la première.

Runtime / bibliothèqueConversion chaîne → octetsQui décide
Node jsonwebtokenoctets UTF-8 de la chaînela bibliothèque
Python PyJWToctets UTF-8 de la chaînela bibliothèque
Java jjwt, ancienne surcharge Stringcodec base64 de la plateforme, d’après jwtk/jjwt#204la bibliothèque
Go golang-jwtprend directement un []bytevous, au site d’appel
.NETprend directement un byte[]vous, au site d’appel

La ligne Java est la source historique des ennuis entre stacks, et elle mérite d’être énoncée précisément. Dans les anciennes versions de jjwt, signWith(SignatureAlgorithm, String) et ses variantes faisaient passer la String par un codec base64 au lieu d’en prendre les octets bruts, tandis que les surcharges byte[] utilisaient les octets tels quels. Un service Node et un service Java partageant un même secret étaient donc en désaccord. Cette API String est dépréciée depuis jjwt 0.10, et la forme moderne est explicite :

SecretKey key = Keys.hmacShaKeyFor(secretBytes);

Ce n’est pas « la façon dont Java fait les JWT ». C’est une surcharge héritée d’une seule bibliothèque, et le code jjwt actuel qui passe un byte[] ne présente aucune ambiguïté. Le rapport miroir côté Node est auth0/node-jsonwebtoken#208, où des tokens signés en Java refusaient de se vérifier en Node. Des signalements similaires visent firebase/php-jwt du côté PHP, voir firebase/php-jwt#153, mais nous n’avons pas vérifié nous-mêmes la gestion des octets de cette bibliothèque : traitez-le comme une piste, pas comme un diagnostic.

Go et .NET relèvent d’une autre catégorie. Aucune des deux bibliothèques ne décide à votre place : toutes deux vous tendent le paramètre []byte / byte[] et vous laissent trancher. []byte(secret) et Encoding.UTF8.GetBytes(secret) donnent de l’UTF-8, tandis que Convert.FromBase64String(secret) donne les octets décodés. Le bug, quand il survient, vit dans votre site d’appel, ce qui est une bonne nouvelle : il est visible dans votre propre diff.

Mon secret JWT est-il en base64 ou en UTF-8 ?

Aucun indicateur dans le token ne vous le dira. Il faut raisonner sur la chaîne elle-même :

  1. N’utilise-t-elle que A–Z a–z 0–9 + / = (ou - et _) ? Si oui, elle pourrait être du base64. Un secret contenant une espace, un ! ou un # ne peut pas l’être.
  2. Sa longueur est-elle un multiple de 4, ou se termine-t-elle par du padding = ? Les deux sont de forts indices que quelque chose l’a encodée en base64 en chemin.
  3. Son décodage base64 produit-il des octets sensés ? Passez-la dans le décodeur Base64. De l’ASCII lisible, ou exactement 32 octets d’allure aléatoire, penchent pour du base64. Du charabia indique que la chaîne n’a jamais été encodée.

Un secret comme c2VjcmV0LWtleS0xMjM0NTY3ODkwYWJjZGVm déclenche les trois tests, et c’est bien ce qui le rend dangereux : il est ambigu, et les deux lectures sont plausibles. Les secrets qui contiennent un - ou un _ sont ambigus d’une façon plus retorse encore, puisqu’ils sont du base64url valide mais du base64 standard invalide.

Quand le raisonnement ne suffit pas, calculez les deux. Prenez le signing input, passez-le deux fois au HMAC-SHA256 dans le générateur HMAC, une fois avec le secret comme texte, une fois avec les octets décodés, puis comparez chaque résultat au troisième segment du token. L’un des deux correspondra, et cela vous dira quel côté de votre système a raison.

Les caractères ne sont pas des octets

Le piège voisin consiste à compter des caractères alors que l’exigence est exprimée en octets. La RFC 7518 §3.2 fixe le plancher de clé pour HMAC-SHA en bits, pas en caractères, et l’encodage change le compte :

Comment vous l’écrivezEntropieOctets équivalentsPour HS256 (exige ≥ 256 bits)
32 caractères hex128 bits16 octets❌ sous le plancher
32 caractères base64192 bits24 octets❌ sous le plancher
32 octets aléatoires256 bits32 octets✅ conforme (64 caractères en hex, 44 en base64 avec padding)

Un « secret de 32 caractères » peut valoir de 128 à 256 bits selon l’alphabet. Le problème est orthogonal à celui de l’interprétation des octets vu plus haut, mais il mord les mêmes personnes : une équipe qui mesure en caractères est en général une équipe qui n’a jamais regardé les octets. Pour les règles de choix, longueur, encodage et rotation, rendez-vous sur le générateur de secret JWT ; ses notes de référence les traitent correctement, inutile de les dupliquer ici.

4. Le secret lui-même a été pollué

Vos deux services s’accordent sur l’interprétation des octets. La signature échoue quand même. Vérifiez maintenant que le secret chargé de chaque côté est bien celui que vous croyez avoir écrit, car la plomberie d’environnement est remarquablement douée pour ajouter un octet.

Retour à la ligne final dans .env. JWT_SECRET=abc suivi d’un saut de ligne peut être chargé comme abc\n par certains lecteurs. Un octet de plus, et HMAC produit une sortie sans le moindre rapport. Aucune ressemblance partielle ne viendra vous mettre la puce à l’oreille.

Guillemets lus comme des données. JWT_SECRET="abc" vaut abc pour certains chargeurs et "abc" pour d’autres, en particulier selon que le fichier est sourcé par un shell ou analysé par une bibliothèque. Le env_file de Docker Compose et un parseur .env peuvent être en désaccord sur le même fichier.

Caractères invisibles issus du copier-coller. Copier un secret depuis Slack, un wiki ou un PDF peut embarquer une espace sans chasse (U+200B, octets e2 80 8b) ou une espace insécable (U+00A0, octets c2 a0). Invisibles dans n’importe quel éditeur, l’une comme l’autre modifient le HMAC.

Déformations en CI et en conteneur. Un secret qui passe par l’interpolation du shell voit ses $ développés ou ses antislashs avalés. Certains systèmes de CI rognent les valeurs, d’autres non. Les secrets Kubernetes sont en base64 dans le manifeste et bruts dans le conteneur, un piège de double décodage à lui tout seul.

Le remède consiste à cesser de regarder le secret pour commencer à le mesurer. De chaque côté, affichez la longueur et une empreinte, jamais la valeur :

printf '%s' "$JWT_SECRET" | wc -c
printf '%s' "$JWT_SECRET" | shasum -a 256 | cut -c1-16

Lancez les deux commandes sur le signataire et sur le vérificateur, puis comparez les deux sorties. Longueur identique et empreinte identique signifient que le secret n’est pas votre problème : retournez à la section 3. Une longueur supérieure de un à celle attendue, c’est le retour à la ligne final. Supérieure de deux, ce sont les guillemets.

Quand la longueur ne colle pas et que vous voulez voir exactement ce qu’il y a dedans, faites-en un dump hexadécimal dans un shell local, sur un secret de développement :

printf '%s' "$JWT_SECRET" | xxd

Un 0a à la fin, c’est un saut de ligne. Un 22 au début et à la fin, c’est une paire de guillemets. Un c2 a0 ou un e2 80 8b au milieu, c’est le cas du caractère invisible. Ne lancez pas cette commande sur un secret de production depuis une machine qui expédie sa sortie de terminal quelque part.

Le contrôle équivalent depuis un processus Node ou Python en cours d’exécution :

const s = process.env.JWT_SECRET ?? '';
console.log(Buffer.byteLength(s, 'utf8'), JSON.stringify(s.slice(-3)));
import os
s = os.environ["JWT_SECRET"]
print(len(s), len(s.encode("utf-8")), repr(s[-3:]))

En Python, si len(s) compte moins que len(s.encode("utf-8")), c’est qu’il y a des caractères non-ASCII dans un secret censé être en ASCII.

5. L’algorithme et le type de clé ne correspondent pas

Le header alg et la clé que vous passez doivent appartenir à la même famille. HS256 veut un secret partagé, c’est-à-dire une chaîne d’octets. RS256 et ES256 veulent une clé asymétrique, c’est-à-dire un PEM ou un JWK. Croisez les fils et l’échec prend une forme ou une autre selon l’indulgence de la bibliothèque : erreur de type limpide chez les plus strictes, simple invalid signature chez les autres.

Les variantes courantes :

  • Le header annonce HS256 et le vérificateur tend à la bibliothèque une clé publique PEM. Certaines bibliothèques appliquent le HMAC au texte du PEM et signalent un écart de signature.
  • Le header annonce RS256 et le vérificateur lui tend la chaîne du secret HMAC.
  • Le vérificateur ne passe aucune liste d’algorithmes et laisse la bibliothèque déduire depuis alg : une dérive de configuration côté signature change alors silencieusement le comportement du vérificateur.

C’est ce dernier cas qui transforme un bug de configuration en bug de sécurité, alors fixez explicitement l’algorithme à chaque appel de vérification :

jwt.verify(token, key, { algorithms: ['HS256'] });
jwt.decode(token, key, algorithms=["HS256"])

Cette contrainte transforme aussi les erreurs de signature vagues en erreurs précises. Si un token arrive avec alg: RS256 alors que votre liste blanche dit HS256, vous obtenez une erreur d’algorithme explicite qui nomme les deux valeurs.

Cette section décrit une erreur de configuration : vos deux composants se contredisent, sans le moindre adversaire. Il existe une panne voisine de même forme, où un attaquant réécrit alg de RS256 en HS256 et signe en utilisant votre clé publique comme secret HMAC. C’est la confusion d’algorithme, une attaque et non un bug, traitée dans les bonnes pratiques de sécurité JWT avec le reste du modèle de menace. La défense, une liste blanche explicite, se trouve être la même, ce qui est un bon argument pour l’appliquer même quand vous ne chassez qu’un bug.

6. Le token a changé en transit

Avant d’accuser les clés, confirmez que le vérificateur a bien reçu la chaîne produite par le signataire. Un JWT est fragile exactement là où les chaînes de caractères sont fragiles.

Le préfixe Bearer. Authorization: Bearer eyJhbGci... est une valeur d’en-tête, pas un token. Découper sur le mauvais séparateur, ou découper une fois et garder la mauvaise moitié, vous fait vérifier Bearer eyJhbGci... ou une chaîne vide. Retirez-le délibérément :

const token = req.headers.authorization?.replace(/^Bearer\s+/i, '').trim();

Espaces et retours à la ligne. Les tokens copiés depuis un terminal reviennent à la ligne. Les tokens stockés en YAML sont repliés. Un seul \n inséré dans le troisième segment produit un écart de signature, pas une erreur d’analyse, parce que les décodeurs base64url ignorent souvent les espaces alors que la comparaison de chaînes, elle, ne les ignore pas.

Encodage d’URL. Un token passé en paramètre de requête peut revenir avec . transformé en %2E, ou avec - et _ traduits par un encodeur trop zélé. Décodez une fois, exactement une fois.

Troncature. Les cookies plafonnent autour de 4 Ko chacun, et un token RS256 avec quelques claims dépasse ce seuil sans difficulté. Un token tronqué échoue en général au décodage base64, mais s’il est coupé sur une frontière de 4 caractères, vous récupérez à la place un token d’apparence valide avec une mauvaise signature.

Deux commandes tranchent la question. Un JWT bien formé comporte exactement deux points :

printf '%s' "$TOKEN" | tr -cd '.' | wc -c

Et chaque caractère doit appartenir à l’alphabet base64url, donc la commande suivante ne devrait rien afficher du tout :

printf '%s' "$TOKEN" | tr -d 'A-Za-z0-9._-' | xxd

Toute sortie de la seconde commande nomme votre problème : 3d est un padding = qui n’a rien à faire là, 2b ou 2f sont les + et / du base64 standard là où le base64url attend - et _, et 20 est une espace égarée.

7. Pannes propres à RS256 et ES256

Les algorithmes asymétriques échangent le problème du secret contre un problème de gestion de clés. Les modes de défaillance sont assez différents pour mériter leur propre liste.

PKCS#1 contre PKCS#8. Ce sont deux formats de conteneur pour la même clé RSA, et ils se distinguent visuellement par un mot dans la ligne d’en-tête :

-----BEGIN RSA PRIVATE KEY-----      ← PKCS#1
-----BEGIN PRIVATE KEY-----          ← PKCS#8

Les bibliothèques n’acceptent pas les mêmes. Quand l’une rejette franchement le format, vous obtenez une erreur claire ; quand elle l’analyse à moitié, vous pouvez obtenir une signature qui ne se vérifie jamais. Convertissez plutôt que de vous battre :

openssl pkcs8 -topk8 -nocrypt -in pkcs1.pem -out pkcs8.pem

Les clés sont inversées. Signer avec la clé publique, ou vérifier avec la privée. Évident sur le papier, facile à commettre quand les deux fichiers cohabitent dans le même répertoire sous des noms qui diffèrent de quatre caractères. Vérifiez qui est qui :

openssl rsa -in key.pem -noout -text | head -1

Une clé privée affiche la taille de son module en tant que clé privée ; une clé publique déclenche une erreur, sauf si vous ajoutez -pubin.

Dérive du JWKS et du kid. Avec un endpoint JWKS, le vérificateur choisit une clé en faisant correspondre le kid du token au jeu de clés. Trois choses tournent mal ici : le signataire a fait une rotation et le JWKS mis en cache par le vérificateur est périmé ; le token n’a pas de kid et le vérificateur prend la première clé du jeu ; ou deux environnements publient des kid qui se chevauchent. Si vous soupçonnez ce cas, récupérez le JWKS à neuf et confirmez que le kid exact du header du token s’y trouve.

Encodage de la signature ES256. Une signature ECDSA est une paire d’entiers, r et s, et il existe deux façons de la sérialiser. Les piles cryptographiques généralistes émettent souvent du DER, une structure ASN.1 de longueur variable. La RFC 7518 §3.4 exige à la place la forme JOSE : r et s complétés chacun à une longueur fixe puis concaténés, soit 64 octets pour P-256. Une signature DER déposée dans un JWT n’a même pas la bonne longueur. Un token ES256 dont le troisième segment ne se décode pas en exactement 64 octets a donc été fabriqué par quelque chose qui a sauté la conversion.

Pour isoler si le problème vient de votre clé ou de votre pipeline, signez le même payload de façon indépendante dans l’encodeur JWT et comparez le résultat à ce qu’a produit votre service. Des signatures identiques désignent le transport ou le traitement des claims. Des signatures différentes désignent la clé.

8. Des erreurs qui ressemblent à des échecs de signature sans en être

Certaines sont réellement mal nommées par les bibliothèques elles-mêmes, et c’est ainsi qu’elles atterrissent dans le mauvais rapport de bug.

SymptômeCe dont il s’agit vraimentOù regarder
PyJWT ExpiredSignatureErrorexp est dans le passé. Le nom dit « signature » ; la cause est un claim.Décalage d’horloge entre hôtes, ou TTL trop court
PyJWT ImmatureSignatureErrornbf est dans le futurL’horloge du signataire est en avance sur celle du vérificateur
Node TokenExpiredErrorexp est dans le passéComme ci-dessus
401 générique, sans détailLe framework a réduit tous les échecs de vérification à une seule réponseActivez la journalisation des erreurs au niveau de la bibliothèque
Fonctionne quelques minutes, puis échoueExpiration du token, pas la signatureComparez iat et exp aux horloges des deux hôtes
Échoue pour une seule audienceaud ou iss ne correspond pasLa liste d’audiences attendue par le vérificateur

Le nommage de PyJWT est le piège le plus marquant. ExpiredSignatureError contient le mot « signature » mais est levée pendant la validation des claims, bien après que la signature a été vérifiée avec succès. Chercher la chaîne d’erreur mène droit à de la documentation de dépannage de signature, et des heures disparaissent dans la mauvaise partie du problème.

Le décalage d’horloge produit le motif le plus déroutant : des échecs intermittents qui ne corrèlent avec rien dans votre code. Si l’horloge d’un hôte prend de l’avance, les tokens fraîchement émis échouent à la validation de nbf ou d’iat dès leur arrivée, et les échecs se déplacent à mesure que la dérive grandit. Commencez par comparer date -u sur les deux machines. La plupart des bibliothèques acceptent un paramètre de tolérance, ce qui est le bon correctif pour un décalage que vous ne pouvez pas supprimer, et le mauvais pour une horloge réellement en panne.

La règle générale : si l’échec dépend de l’heure, de l’hôte ou de l’audience, ce n’est pas un problème de signature. Les échecs de signature sont déterministes. Le même token et la même clé échouent de la même façon, indéfiniment.

9. Une procédure de dépannage reproductible

Exécutez ces étapes dans l’ordre. Chacune trouve le bug ou élimine une branche, et s’arrêter tôt est précisément le but.

  1. Décodez le header. Collez le token dans le décodeur JWT et notez alg et kid. Cela conditionne toute la suite et ne demande aucune clé.
  2. Contrôlez la forme du token. Exactement deux points, uniquement des caractères base64url, pas de préfixe Bearer, pas d’espaces. Utilisez les deux commandes de la section 6. Cela élimine la corruption en transit.
  3. Fixez l’algorithme sur l’appel de vérification. S’il y a un écart entre alg et votre liste blanche, vous obtenez désormais une erreur explicite qui nomme les deux, au lieu d’une erreur générique.
  4. Prenez l’empreinte de la clé des deux côtés. Affichez la longueur en octets et un SHA-256 tronqué sur le signataire et sur le vérificateur, comme en section 4. Des valeurs différentes signifient que la plomberie est en cause, et vous n’atteindrez jamais l’étape 5.
  5. Si les deux côtés sont dans des langages différents, tranchez l’interprétation des octets. Consultez le tableau de la section 3, décidez explicitement si le secret est du texte ou du base64, et faites en sorte que les deux côtés le disent dans le code plutôt que par défaut.
  6. Resignez le même payload de façon indépendante. Utilisez l’encodeur JWT avec la clé que vous croyez correcte, et comparez son troisième segment à celui de votre token. Une correspondance signifie que votre côté signature va bien et que le problème est chez le vérificateur.
  7. Recoupez le HMAC à la main. Passez le signing input dans le générateur HMAC avec les deux interprétations d’octets. Celle qui correspond au token vous dit quel côté modifier.

Vous avez fait les sept étapes et il vous faut encore de l’aide ? La plupart des rapports de bug s’enlisent parce qu’ils omettent les faits qui déterminent la réponse. Incluez ceci :

  • La valeur alg du header, et la présence ou non d’un kid
  • Le langage, la bibliothèque et la version exacte des deux côtés, signature et vérification
  • La longueur en octets du secret des deux côtés, et les 16 premiers caractères hexadécimaux de son SHA-256 (jamais le secret lui-même)
  • Si le secret est stocké en texte ou en base64, et comment chaque côté le convertit
  • Le signing input complet. Les deux premiers segments ne sont pas sensibles ; quiconque détient le token peut les lire de toute façon
  • Pour RS256 et ES256 : la ligne d’en-tête du PEM, mot pour mot

Cette liste transforme un « ma signature JWT ne correspond pas » auquel personne ne peut répondre en une question que quelqu’un peut effectivement résoudre, en général du premier coup.

FAQ

Pourquoi le même secret fonctionne-t-il dans un langage et échoue-t-il dans un autre ?

Parce que les bibliothèques ne s’accordent pas sur la façon de transformer une chaîne de secret en octets de clé. Node jsonwebtoken et Python PyJWT utilisent l’UTF-8 ; l’ancienne surcharge String de jjwt passait par un codec base64 (jwtk/jjwt#204) ; Go et .NET laissent la décision à votre site d’appel. Mêmes caractères, octets différents, donc HMAC différent.

La signature porte-t-elle sur le payload décodé ou sur la chaîne encodée ?

Sur la chaîne encodée. La RFC 7515 définit le signing input comme base64url(header) + "." + base64url(payload) en ASCII littéral. Toute couche qui désérialise le payload puis le re-sérialise modifie l’ordre des clés, les espaces ou le formatage des nombres, produisant une chaîne différente et donc une signature différente.

Mon secret ressemble à du base64 : dois-je le décoder avant de signer ?

Seulement si l’autre côté le fait aussi. Il n’y a pas de bonne réponse dans l’absolu ; l’exigence est que les deux extrémités s’accordent. Vérifiez si la chaîne n’utilise que des caractères base64 et si sa longueur est un multiple de quatre, puis rendez le choix explicite dans le code des deux côtés au lieu de vous en remettre aux valeurs par défaut.

Un retour à la ligne final dans .env peut-il vraiment casser la signature ?

Oui. HMAC consomme des octets, et abc\n fait quatre octets là où abc en fait trois. La signature obtenue n’a rien de commun avec la bonne. Affichez printf '%s' "$JWT_SECRET" | wc -c sur les deux hôtes ; une longueur supérieure de un à celle attendue, c’est presque toujours ça.

Comment savoir si c’est le secret ou l’algorithme ?

Lisez d’abord alg dans le header. S’il commence par HS, il vous faut un secret partagé et un PEM échouera. S’il commence par RS, PS ou ES, il vous faut une paire de clés et une chaîne de secret échouera. Une fois que alg et le type de clé appartiennent à la même famille, les échecs restants sont des problèmes de contenu de clé.

Pourquoi jwt.io dit-il que la signature est valide alors que mon serveur la rejette ?

Parce que l’outil en ligne et votre serveur peuvent interpréter le secret différemment : l’un comme du texte UTF-8, l’autre comme du base64. L’outil valide contre les octets qu’il a dérivés, pas contre ceux qu’a dérivés votre serveur. Par ailleurs, ne collez jamais un secret de production sur un site tiers ; utilisez une clé de développement.

Un token expiré peut-il provoquer une erreur « invalid signature » ?

Non. La vérification de signature s’exécute avant la validation des claims, l’expiration n’en est donc jamais la cause. L’expiration remonte séparément sous la forme de TokenExpiredError en Node ou d’ExpiredSignatureError en PyJWT, dont le nom induit en erreur puisque la signature a été vérifiée sans problème et que seul exp a échoué.

Conclusion

Un écart de signature n’est presque jamais un problème de cryptographie. HMAC-SHA256 fonctionne. RSA fonctionne. Ce qui casse, c’est la frontière où une chaîne devient des octets : un codec base64 d’un côté et de l’UTF-8 de l’autre, un saut de ligne qu’un chargeur de configuration a conservé, un payload qu’une gateway a serviablement re-sérialisé. Toutes les causes de ce guide sont des désaccords sur des octets.

Rendez donc les octets explicites et cessez de vous fier aux valeurs par défaut. Écrivez noir sur blanc, dans la documentation de votre équipe, si le secret partagé est stocké en texte brut ou en base64, et faites en sorte que chaque service le convertisse de la façon déclarée au lieu d’hériter de ce que sa bibliothèque a supposé. Pour les systèmes qui couvrent plusieurs langages, stockez les secrets en hexadécimal ou en base64 et décodez-les explicitement à chaque site d’appel : une ligne par service, et l’ambiguïté disparaît. Ajoutez ensuite l’empreinte de longueur en octets de la section 4 à votre health check, pour que le prochain écart se manifeste comme un avertissement au démarrage plutôt que comme un 401 en production.

Tags: jwt authentication debugging hmac api-security

Articles connexes

Voir tous les articles