Ce qu’est réellement un JWT
Un JSON Web Token est une chaîne compacte, compatible avec les URL, qui encode un ensemble de revendications (claims) accompagnées d’une signature. Il est défini par la RFC 7519 et s’appuie sur deux spécifications complémentaires : JSON Web Signature (JWS) pour la signature et JSON Web Encryption (JWE) pour la confidentialité. La quasi-totalité des JWT que vous rencontrerez en pratique sont des JWS.
Le jeton est composé de trois parties encodées en Base64url et séparées par des points : le header, le payload et la signature. Il est autonome (self-contained), ce qui signifie que toutes les informations dont un serveur a besoin pour prendre une décision sont transmises avec la requête. Sa validation ne nécessite aucune requête en base de données, aucun store de session partagé, ni aucun appel à l’émetteur.
Il est important d’être précis sur ce que cela prouve. Un JWT démontre que l’entité qui l’a émis a signé exactement ce payload. Cela ne prouve pas que le jeton vous était destiné, à moins que vous ne vérifiiez l’audience. Cela ne prouve pas que l’utilisateur est toujours autorisé à agir, à moins que vous ne vérifiiez les scopes et les rôles. Enfin, cela ne cache rien, à moins d’utiliser JWE. Chaque propriété de sécurité importante doit être vérifiée explicitement.
Les trois parties décodées
Le header est un petit objet JSON indiquant l’algorithme de signature et le type de token. Le champ alg est le plus critique. typ est presque toujours JWT, et kid identifie la clé utilisée afin que les vérificateurs puissent trouver la bonne lors d’une rotation de clés.
{ "alg": "HS256", "typ": "JWT", "kid": "2026-09" }
Le payload est un objet JSON composé de claims. Les claims enregistrés possèdent des noms standards définis par la spécification : iss pour l’émetteur (issuer), sub pour le sujet (subject), aud pour l’audience, exp pour l’expiration, nbf pour la date de début de validité (not before), iat pour la date d’émission (issued at), et jti pour un identifiant de token unique. Tout le reste correspond à des claims personnalisés que vous définissez vous-même.
{
"iss": "https://auth.example.com",
"sub": "user_42",
"aud": "api.example.com",
"exp": 1760000000,
"iat": 1759999100,
"scope": "read:posts write:posts",
"role": "editor"
}
La signature est calculée à partir du Base64url du header et du payload. Si vous modifiez un seul caractère dans l’un ou l’autre, la signature recalculée ne correspondra plus. C’est cette propriété qui rend un JWT sûr à confier à un client non fiable, et c’est le seul rempart entre un claim légitime et une falsification.
Signé, mais pas chiffré
L’erreur la plus courante concernant les JWT est de penser qu’ils sont secrets. Ce n’est pas le cas. Le payload est en Base64url, ce qui est un encodage et non un chiffrement. Toute personne détenant le token peut décoder chaque claim avec une seule ligne de code.
const [, payload] = token.split(".");
console.log(JSON.parse(atob(payload)));
// { sub: "user_42", role: "editor", scope: "read:posts" }
Cela a deux conséquences. Premièrement, ne placez jamais de secrets, de mots de passe ou de données personnelles dans un JWT que vous ne seriez pas à l’aise de montrer au client. Deuxièmement, considérez le token lui-même comme un identifiant : sa possession suffit pour agir en tant que sujet, c’est pourquoi le stockage et le transport sont si importants plus loin dans ce guide.
Si vous avez réellement besoin de masquer le payload pour le client, utilisez JSON Web Encryption et une bibliothèque qui le supporte. Pour la vaste majorité des systèmes, un JWS signé via TLS est la solution appropriée, et l’ajout du chiffrement ne ferait qu’ajouter une complexité inutile.
Algorithmes de signature : HS256 vs RS256
Deux familles d’algorithmes dominent les déploiements réels.
HS256 est un HMAC avec SHA-256. Le même secret sert à signer et à vérifier. C’est rapide, simple et idéal lorsqu’un seul service émet et vérifie ses propres tokens.
import { SignJWT, jwtVerify } from "jose";
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
const token = await new SignJWT({ role: "editor" })
.setProtectedHeader({ alg: "HS256" })
.setSubject("user_42")
.setExpirationTime("15m")
.sign(secret);
await jwtVerify(token, secret, { algorithms: ["HS256"] });
RS256 est un RSA avec SHA-256, et ES256 est un ECDSA sur une courbe première. L’émetteur détient une clé privée pour signer ; tous les autres détiennent la clé publique pour vérifier. Cette asymétrie est la raison pour laquelle les grands systèmes le préfèrent : un serveur de ressources peut vérifier les tokens sans être capable de les générer. ES256 offre la même garantie avec des clés et des signatures beaucoup plus petites.
import { importPKCS8, importSPKI, SignJWT, jwtVerify } from "jose";
const privateKey = await importPKCS8(process.env.JWT_PRIVATE_KEY!, "RS256");
const publicKey = await importSPKI(process.env.JWT_PUBLIC_KEY!, "RS256");
const token = await new SignJWT({ role: "editor" })
.setProtectedHeader({ alg: "RS256", kid: "2026-09" })
.setSubject("user_42")
.setExpirationTime("15m")
.sign(privateKey);
await jwtVerify(token, publicKey, {
algorithms: ["RS256"],
issuer: "https://auth.example.com",
audience: "api.example.com",
});
La règle la plus importante, au-delà du choix de l’algorithme : le vérificateur doit fixer l’algorithme attendu. Ne laissez jamais le header du token décider de la manière dont il est vérifié.
Claims : enregistrées et personnalisées
Les claims enregistrées sont réservées par la spécification et sont reconnues par toutes les bibliothèques sérieuses.
iss— l’émetteur du token. Vérifiez-le pour rejeter les tokens provenant d’un autre environnement.sub— le sujet du token, généralement l’identifiant de l’utilisateur.aud— le destinataire du token. Un token généré pour votre API publique ne devrait pas être accepté par votre API d’administration.exp— la date d’expiration du token, sous forme de timestamp Unix. Définissez-la systématiquement.nbf— “not before” (pas avant). Rarement nécessaire, mais utile pour les déploiements progressifs.iat— la date d’émission. Pratique pour les vérifications d’âge maximum et le débogage.jti— un identifiant unique pour ce token, utilisé pour la détection de rejeu et les listes de blocage.
Les claims personnalisées transportent des données applicatives : scope, role, tenant_id, email. Gardez-les légères et non sensibles. Un token voyage à chaque requête, donc un payload trop volumineux représente une taxe permanente sur la bande passante et la latence.
Il est très tentant d’inclure tout le profil de l’utilisateur dans le token pour éviter une lecture en base de données. Résistez. Les claims deviennent obsolètes dès qu’un rôle change, et vous ne pouvez pas révoquer un token qu’un client détient déjà. N’incluez que ce dont le vérificateur a réellement besoin, et récupérez le reste via une requête.
Création et vérification des tokens
Dans Node, jose est le choix moderne. Il est basé sur les promesses, fonctionne dans tous les runtimes, y compris Cloudflare Workers et Deno, et expose une API concise et rigoureuse. jsonwebtoken est la bibliothèque plus ancienne, basée sur les callbacks, et reste courante dans le code existant.
import { SignJWT } from "jose";
export async function issueAccessToken(userId: string, scope: string) {
return new SignJWT({ scope })
.setProtectedHeader({ alg: "HS256", typ: "JWT" })
.setSubject(userId)
.setIssuer("https://auth.example.com")
.setAudience("api.example.com")
.setIssuedAt()
.setExpirationTime("15m")
.setJti(crypto.randomUUID())
.sign(new TextEncoder().encode(process.env.JWT_SECRET));
}
C’est lors de la vérification que se joue la sécurité. Un vérificateur doit contrôler la signature, fixer l’algorithme et valider exp, iss et aud. Une bibliothèque peut volontiers décoder un token sans le vérifier, et ce payload décodé est alors une entrée contrôlée par l’attaquant.
import { jwtVerify, type JWTPayload } from "jose";
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
export async function verifyAccessToken(token: string): Promise<JWTPayload> {
const { payload } = await jwtVerify(token, secret, {
algorithms: ["HS256"],
issuer: "https://auth.example.com",
audience: "api.example.com",
});
return payload;
}
Remarquez que jwtVerify impose exp et nbf automatiquement, mais ne vérifie iss et aud que si vous les passez en argument. Les omettre constitue une véritable vulnérabilité : un token généré pour un service de staging serait accepté en production, tout comme un token généré pour une application différente.
Jetons d’accès et de rafraîchissement (Access and refresh tokens)
Un jeton unique à longue durée de vie est pratique, mais dangereux. La solution standard consiste à utiliser un couple de jetons avec des durées de vie et des audiences différentes.
- Le access token est éphémère, généralement de 5 à 15 minutes. Il est envoyé à chaque appel API et est le seul jeton que le serveur de ressources voit.
- Le refresh token a une durée de vie longue, allant de quelques jours à plusieurs mois. Il est envoyé uniquement au serveur d’autorisation, en échange d’un nouvel access token.
Cette séparation limite les risques. Si un access token fuite, il devient inutile en quelques minutes. Le refresh token, beaucoup plus sensible, n’est jamais transmis aux serveurs de ressources et peut être renouvelé (rotated) ou révoqué, ce qui permet un contrôle réel de la sécurité.
Rotation des refresh tokens
La rotation signifie que chaque demande de rafraîchissement génère un nouveau refresh token et invalide l’ancien. Si un attaquant vole un refresh token et l’utilise, le client légitime présentera plus tard un token déjà utilisé ; le serveur pourra alors détecter cette réutilisation et révoquer l’ensemble de la famille de tokens.
import { randomUUID } from "node:crypto";
export async function rotateRefreshToken(presented: string) {
const stored = await db.refreshToken.findUnique({ where: { token: presented } });
if (!stored || stored.revokedAt || stored.expiresAt < new Date()) {
if (stored) await revokeFamily(stored.familyId);
throw new Error("invalid_refresh_token");
}
await db.refreshToken.update({
where: { id: stored.id },
data: { revokedAt: new Date(), replacedBy: randomUUID() },
});
return issueTokenPair(stored.userId, stored.familyId);
}
Stockez les refresh tokens hachés, exactement comme vous le feriez pour des mots de passe. Une fuite de base de données ne doit pas fournir d’identifiants exploitables à un attaquant, car un refresh token n’est rien d’autre qu’un mot de passe permettant de contourner le formulaire de connexion.
Où stocker les tokens dans un navigateur
Il n’existe pas d’endroit parfait, seulement des compromis entre le cross-site scripting (XSS) et le cross-site request forgery (CSRF).
- localStorage est lisible par n’importe quel script sur la page. Un seul bug XSS et l’attaquant exfiltre tous les tokens. C’est l’erreur que l’on retrouve systématiquement dans les rapports de violation de données.
- En mémoire, via une variable dans un module, les données sont protégées contre le XSS persistant mais sont perdues lors du rafraîchissement de la page. C’est pourquoi cette méthode est généralement couplée à un refresh token dans un cookie httpOnly.
- Un cookie httpOnly, Secure et SameSite est illisible par JavaScript, ce qui neutralise le vol de tokens via XSS. Cela réintroduit cependant le risque de CSRF, que
SameSite=LaxouStrictassocié à un token CSRF permettent d’atténuer.
Pour une application navigateur, le choix pragmatique par défaut est un access token à courte durée de vie conservé en mémoire et un refresh token rotatif dans un cookie httpOnly limité au point de terminaison (endpoint) de rafraîchissement. Les clients natifs et server-side n’ont pas cette contrainte et peuvent conserver les tokens dans un stockage sécurisé ou simplement en mémoire.
Le compromis de l’absence d’état et la révocation
L’argument principal des JWT est l’absence d’état (statelessness). N’importe quel serveur peut vérifier un jeton sans état partagé, ce qui permet une mise à l’échelle fluide entre les régions et rend le scaling horizontal trivial. Le revers de la médaille est que la révocation est réellement complexe. Un jeton signé reste valide jusqu’à son expiration, que vous ayez supprimé l’utilisateur, modifié son rôle ou qu’il se soit déconnecté entre-temps. Il n’existe aucun registre central à supprimer.
Vous pouvez toutefois reprendre une partie du contrôle :
- Gardez des jetons d’accès avec une durée de vie courte afin que la fenêtre de révocation soit de quelques minutes plutôt que de plusieurs jours.
- Maintenez une deny-list de valeurs
jtipour les cas rares de déconnexion immédiate, vérifiée à chaque requête. Cela réintroduit un état, veillez donc à ce qu’elle reste légère et que les entrées expirent. - Ajoutez un claim
token_versionpar utilisateur et rejetez les jetons dont la version est obsolète. Le changement d’un mot de passe ou d’un rôle incrémente alors cette valeur.
Voici le résumé honnête : les JWT sacrifient la facilité de révocation au profit de la facilité de mise à l’échelle. Si vous avez besoin d’une révocation instantanée partout, les cookies de session appuyés par un store pourraient être plus appropriés, comme détaillé dans Session Auth.
Confusion d’algorithme et alg none
Deux attaques sont suffisamment anciennes pour figurer dans tous les manuels, tout en continuant de trouver des victimes.
La première est alg: none. Un attaquant modifie l’en-tête en {"alg":"none"} et supprime la signature. Un vérificateur naïf qui fait confiance à l’en-tête accepte alors le jeton falsifié. La seconde est la confusion HS/RS. Un service qui attend des jetons RS256 est trompé pour accepter un jeton HS256 signé avec la clé publique RSA, laquelle est, par définition, publique.
Ces deux failles ont la même solution : c’est le vérificateur qui décide de l’algorithme, et non le jeton.
// Good: the verifier decides.
await jwtVerify(token, secret, { algorithms: ["HS256"] });
// Bad: the token decides.
const { header } = decodeProtectedHeader(token);
await jwtVerify(token, secret, { algorithms: [header.alg] });
Rejetez systématiquement alg: none, ne dérivez jamais la clé à partir d’une source non fiable, et traitez l’en-tête comme une donnée, et non comme une instruction.
Scopes et autorisation
L’authentification répond à la question « qui », et les scopes répondent à « quoi ». Un claim scope est une liste de permissions séparées par des espaces, et un middleware la vérifie avant l’exécution d’un handler.
export function requireScope(required: string) {
return (req, res, next) => {
const granted = String(req.user.scope ?? "").split(" ");
if (!granted.includes(required)) {
return res.status(403).json({ error: "insufficient_scope" });
}
next();
};
}
Gardez vos scopes larges et stables, et imposez-les côté serveur. Un jeton sans le scope approprié doit renvoyer une erreur 403 et non 401 : l’appelant est authentifié, mais n’est tout simplement pas autorisé. Pour des modèles d’accès plus riches basés sur des rôles et des attributs, consultez la section RBAC.
JWT vs tokens opaques
Un JWT est un bearer token qui contient sa propre validation. Un token opaque est une chaîne de caractères aléatoire sans signification intrinsèque ; le serveur doit donc effectuer une recherche pour obtenir des informations à son sujet.
Les tokens opaques l’emportent sur la révocation et la confidentialité. Vous pouvez supprimer la session instantanément, et le token ne révèle rien en cas de fuite. En contrepartie, ils nécessitent un aller-retour vers une base de données ou un cache à chaque requête. Les JWT l’emportent sur la scalabilité et l’indépendance. Les services effectuent la vérification localement et n’ont pas besoin de stockage partagé, au prix d’une fenêtre de délai pour la révocation.
De nombreux systèmes en production utilisent les deux : un access token JWT pour la rapidité et un refresh token opaque pour le contrôle. Cet hybride est ce que la plupart des fournisseurs d’identité proposent aujourd’hui, et c’est un excellent choix par défaut lorsque vous hésitez.
Rotation des clés avec kid
Les clés de signature ne doivent pas être permanentes. La rotation permet de limiter les dégâts en cas de compromission d’une clé, et c’est le champ d’en-tête kid qui rend cette rotation invisible pour les clients : le vérificateur lit kid, sélectionne la clé correspondante et vérifie la signature.
import { SignJWT, jwtVerify, createLocalJWKSet } from "jose";
const jwks = createLocalJWKSet({
keys: [{ kty: "oct", kid: "2026-09", k: process.env.JWT_SECRET }],
});
// The verifier resolves the key from the header's kid.
await jwtVerify(token, jwks, { algorithms: ["HS256"] });
Lors d’une rotation, publiez la nouvelle clé aux côtés de l’ancienne, signez les nouveaux jetons avec le nouveau kid, et continuez à vérifier la clé précédente jusqu’à ce que tous les jetons signés avec celle-ci aient expiré. Si vous la supprimez trop tôt, vous déconnecterez tous les utilisateurs actifs d’un seul coup. Pour les clés asymétriques, publiez un document JWKS à une URL conventionnelle (well-known) et laissez les serveurs de ressources le mettre en cache.
Durée de vie des tokens en pratique
L’expiration est un curseur entre sécurité et commodité. Il n’y a pas de réponse universelle, mais la structure d’une configuration par défaut raisonnable reste cohérente.
- Access tokens : 5 à 15 minutes. Assez court pour qu’une fuite devienne rapidement inutile, assez long pour ne pas avoir à rafraîchir à chaque requête.
- Refresh tokens : 7 à 30 jours, avec rotation et fenêtre glissante. Assez long pour maintenir les utilisateurs connectés, assez court pour qu’un token abandonné finisse par expirer.
- Limite de session absolue : 30 à 90 jours. Un âge maximum après lequel l’utilisateur doit s’authentifier à nouveau, peu importe la fréquence de ses rafraîchissements.
Si vos utilisateurs se plaignent d’être déconnectés, la solution est un silent refresh plus fluide, et non un access token plus long. Un access token de 24 heures est une faille de révocation qui ne demande qu’à être exploitée.
Déboguer un token sans lui faire confiance
Lorsqu’une requête échoue avec une erreur 401, vous souhaitez examiner les claims du token sans pour autant affaiblir la vérification. Le décodage est sans risque tant que vous traitez le résultat comme une donnée non fiable.
import { decodeJwt, decodeProtectedHeader } from "jose";
const header = decodeProtectedHeader(token);
const claims = decodeJwt(token);
console.log({ alg: header.alg, kid: header.kid });
console.log({
sub: claims.sub,
iss: claims.iss,
aud: claims.aud,
exp: new Date((claims.exp ?? 0) * 1000).toISOString(),
expired: (claims.exp ?? 0) * 1000 < Date.now(),
});
Les deux erreurs les plus fréquentes sont un mismatch de aud après le renommage d’un service et un mismatch de iss lors d’un changement d’environnement. Il s’agit dans les deux cas de problèmes de configuration, et ils restent invisibles tant que vous n’affichez pas les claims.
Tester les tokens
Vous devriez être capable de tester une route authentifiée sans avoir à déployer un fournisseur d’identité. Puisqu’un JWT n’est qu’une chaîne signée, un helper de test qui signe un token avec le même secret de test est suffisant.
import { SignJWT } from "jose";
import request from "supertest";
import app from "../app.js";
const secret = new TextEncoder().encode("test-secret");
async function tokenFor(scope = "read:posts") {
return new SignJWT({ scope })
.setProtectedHeader({ alg: "HS256" })
.setSubject("user_1")
.setIssuer("https://auth.example.com")
.setAudience("api.example.com")
.setExpirationTime("5m")
.sign(secret);
}
test("rejects a request without a token", async () => {
await request(app).get("/posts").expect(401);
});
test("accepts a request with a valid token", async () => {
const token = await tokenFor();
await request(app)
.get("/posts")
.set("authorization", `Bearer ${token}`)
.expect(200);
});
Testez également les cas négatifs : un token expiré, un token avec une audience incorrecte et un token signé avec une clé différente. Ce sont ces vérifications qui vous protègent, elles méritent donc autant de couverture de tests que le cas nominal.
Bonnes pratiques
- Configurez toujours
exp; limitez la durée de validité des jetons d’accès à 15 minutes ou moins. - Fixez l’algorithme sur le vérificateur et rejetez
alg: none. - Validez
iss,aud,expetnbf; ne faites jamais confiance à une revendication (claim) que vous n’avez pas vérifiée. - Ne stockez jamais les secrets et les clés privées dans le contrôle de version et chargez-les depuis un gestionnaire de secrets.
- Stockez les jetons de rafraîchissement (refresh tokens) sous forme de hash et effectuez une rotation à chaque utilisation.
- Gardez les revendications courtes et non sensibles ; le payload est lisible.
- Dans les navigateurs, privilégiez les cookies httpOnly ou la mémoire au localStorage.
- Prévoyez la révocation avec des durées de vie courtes, une deny-list
jtiou une version de jeton. - Utilisez
josepour le nouveau code ; il est basé sur les promesses et portable entre les différents runtimes.
Erreurs courantes
- Supposer que le payload est chiffré simplement parce qu’il ressemble à du charabia.
- Lire les claims avec
decodeet les considérer comme vérifiés. - Laisser le header
algdu token choisir l’algorithme de vérification. - Accepter un token sans vérifier
aud, permettant ainsi l’utilisation de tokens de staging en production. - Utiliser un secret faible ou partagé, ou le commiter dans le repository.
- Fixer l’expiration à 30 jours parce que le rafraîchissement est fastidieux.
- Stocker les tokens dans le localStorage et considérer que le problème est réglé.
- Inclure un rôle dans le token et ne jamais l’invalider lorsque le rôle change.
- Traiter une erreur 401 et une erreur 403 comme étant la même erreur.
Et après ?
Les JWT ne sont qu’un outil parmi d’autres dans l’arsenal de la gestion d’identité. Le guide sur OAuth 2.0 explique comment les jetons sont réellement obtenus via l’autorisation déléguée, Session Auth traite de l’alternative basée sur les cookies lorsque vous avez besoin d’une révocation instantanée, et API Keys détaille les identifiants à longue durée de vie pour les clients machines. Si vous souhaitez voir ce code de vérification intégré dans un serveur réel, consultez la section Node.js.