Sécurité et identité
Sécuriser des API REST avec JWT : vérification, claims et révocation
Avancé9 min de lectureMis à jour
Cette page a été traduite de l'anglais avec l'aide de l'IA, puis vérifiée par rapport à l'original. Lire la version anglaise
Un JSON Web Token ne vaut que par la vérification qu'on lui applique, et les vulnérabilités JWT bien connues sont toutes des défauts de cette vérification, pas du format. Ce guide couvre la structure JWS et JWE, la liste blanche d'algorithmes et la rotation des clés avec JWKS, les claims à contrôler impérativement, le problème de la révocation dans une conception stateless, l'endroit où un navigateur doit conserver un token et les protections dont une API durcie a encore besoin en bordure.
Ce que vous allez apprendre
- Expliquer la structure JWS et JWE, et ce qu'une signature prouve ou ne prouve pas
- Vérifier un token avec un algorithme en liste blanche et une clé choisie dans le JWKS par son kid
- Valider les claims iss, aud, exp, nbf et scope, et rejeter tout ce qui manque
- Comparer tokens stateless et tokens opaques, et concevoir une stratégie de révocation réaliste
- Décider où un client navigateur doit conserver un token et quelle durée de vie lui donner
- Superposer rate limiting, validation des entrées et autorisation au niveau des ressources en bordure d'API
La sécurité d'API par tokens a un mode de défaillance confortable : tout fonctionne. Un token mal formé est rejeté par le parser, un token valide laisse passer la requête, et l'intégration est déclarée terminée. Ce que ce scénario ne teste jamais, c'est précisément le cas pour lequel le dispositif existe : un attaquant qui présente un token qu'il a fabriqué lui-même. Presque toutes les vulnérabilités JWT publiées sont des raccourcis de vérification qui n'apparaissent qu'à ce test-là : un algorithme non figé, une clé choisie d'après l'en-tête du token lui-même, une audience jamais comparée, une expiration lue mais jamais appliquée.
Ce guide porte sur la façon de mener correctement ce travail de vérification, et sur les questions de conception qu'entraîne le choix d'un token stateless : comment révoquer, quelle durée de vie donner à un token, où un navigateur doit le conserver, et ce que la bordure de l'API doit encore assurer. L'obtention des tokens est traitée dans OAuth 2.0, et l'ID token propre à l'identité dans OpenID Connect ; ici, le token est déjà arrivé dans un en-tête Authorization et la question est de savoir quoi en faire.
De quoi est fait un JWT
Un JWT est un ensemble de claims en JSON, sérialisé de façon compacte. Sous sa forme JWS, la plus courante, il comporte trois segments base64url séparés par des points : l'en-tête, le payload et la signature.
eyJhbGciOiJSUzI1NiIsInR5cCI6ImF0K2p3dCIsImtpZCI6InNpZy0yMDI2LTAzIn0
.eyJpc3MiOiJodHRwczovL3Nzby5leGFtcGxlLmNvbSIsInN1YiI6InUtODgyMzEiLCJhdWQiOiJodHRwczovL2FwaS5leGFtcGxlLmNvbSJ9
.QVhNc2lnbmF0dXJlYnl0ZXNnb2hlcmU
{ "alg": "RS256", "typ": "at+jwt", "kid": "sig-2026-03" }
{
"iss": "https://sso.example.com",
"sub": "u-88231",
"aud": "https://api.example.com",
"exp": 1789012645,
"nbf": 1789012340,
"iat": 1789012345,
"jti": "3f7c1e02-77aa-4f9c-9d31-2b6c0a5e77b1",
"scope": "orders.read orders.write",
"client_id": "web-portal"
}
Le base64url n'est pas une mesure de sécurité : un décodeur JWT exécuté dans le navigateur, voire un simple décodeur base64, lit le payload instantanément. Partez du principe que le client, et quiconque met la main sur le token, peut lire chaque claim, et n'y placez jamais d'identifiants internes, de données personnelles ou d'indices d'autorisation que vous n'afficheriez pas à l'écran. typ: at+jwt marque explicitement le token comme un access token OAuth, ce qui constitue une défense contre le fait qu'un token destiné à un usage soit accepté pour un autre.
JWE est l'alternative chiffrée : cinq segments, avec un payload chiffré pour la clé du destinataire. Cela impose une gestion de clés côté réception et ne se justifie que lorsque la confidentialité des claims compte vraiment ; un compromis fréquent consiste à exposer un petit access token opaque en bordure et à faire circuler un JWT signé à l'intérieur du périmètre de confiance.
Vérifier la signature
La signature prouve qu'une partie détenant la clé de signature a produit exactement cette séquence d'octets et qu'elle n'a pas été modifiée. Elle ne prouve en revanche ni que le token vous était destiné, ni qu'il est encore valide, ni que l'utilisateur existe toujours. Réussir l'étape de la signature suppose trois décisions précises.
Mettez l'algorithme en liste blanche. Décidez côté serveur quels algorithmes vous acceptez et rejetez tous les autres, sans demander la permission à l'en-tête alg du token. Deux attaques classiques reposent sur la confiance accordée à ce champ : alg: none, où une bibliothèque considère une signature vide comme valide ; et la confusion d'algorithme, où un service configuré pour RS256 reçoit un token signé en HS256 avec la clé publique RSA comme secret HMAC — la clé publique est, après tout, publique. Les deux disparaissent dès que le vérificateur est construit avec un algorithme fixe.
Choisissez la clé par son kid dans un jeu de clés de confiance. Ne récupérez jamais une clé depuis une URL fournie dans l'en-tête du token lui-même (jku) et ne faites jamais confiance à une clé embarquée (jwk). Récupérez le JWKS de l'émetteur à partir de son document de discovery, mettez-le en cache et rafraîchissez-le quand un kid inconnu apparaît — avec un rate limit, pour qu'un attaquant ne puisse pas déclencher un appel par requête.
{
"keys": [
{ "kty": "RSA", "use": "sig", "alg": "RS256", "kid": "sig-2026-03",
"n": "wV5f8Q9c2u...", "e": "AQAB" },
{ "kty": "EC", "use": "sig", "alg": "ES256", "kid": "sig-2026-09",
"crv": "P-256", "x": "f83OJ3D2xF...", "y": "x_FEzRu9m36..." }
]
}
Préférez la signature asymétrique. Avec RS256 ou ES256, le resource server ne détient que la clé publique : il peut vérifier, pas émettre. HS256 partage un même secret entre l'émetteur et chaque vérificateur, ce qui signifie que n'importe quel vérificateur peut forger des tokens pour l'ensemble du parc.
@Bean
JwtDecoder jwtDecoder(OAuth2ResourceServerProperties props) {
NimbusJwtDecoder decoder = NimbusJwtDecoder
.withJwkSetUri(props.getJwt().getJwkSetUri())
.jwsAlgorithms(algs -> { algs.add(SignatureAlgorithm.RS256); algs.add(SignatureAlgorithm.ES256); })
.build();
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
new JwtTimestampValidator(Duration.ofSeconds(30)), // exp et nbf, décalage d'horloge borné
new JwtIssuerValidator("https://sso.example.com"),
new JwtClaimValidator<List<String>>("aud",
aud -> aud != null && aud.contains("https://api.example.com")),
new JwtClaimValidator<String>("typ", "at+jwt"::equals)));
return decoder;
}
Le branchement de ce décodeur dans une filter chain est expliqué dans les bases de Spring Security ; ce qui compte ici, c'est que la liste d'algorithmes, l'émetteur, l'audience et le décalage d'horloge sont tous explicites plutôt que laissés aux valeurs par défaut.
Valider les claims
Une signature vérifiée n'est que le début de la checklist, pas sa fin.
| Claim | Règle |
|---|---|
iss | Correspondance exacte avec l'émetteur attendu ; aucune comparaison par préfixe ou suffixe |
aud | Doit contenir l'identifiant de cette API — c'est ce qui bloque un token émis pour un autre service |
exp | Rejeter les tokens expirés ; tolérer au plus 30 à 60 secondes de décalage d'horloge |
nbf | Rejeter les tokens pas encore valides, avec la même tolérance |
iat | Utile pour rejeter des tokens anormalement anciens et pour les politiques de fraîcheur |
sub | Le principal ; à combiner avec iss avant de s'en servir comme clé locale |
scope | À appliquer pour l'endpoint appelé |
jti | Nécessaire si vous mettez en place un usage unique ou une deny list |
Rejetez les claims absents au lieu de leur attribuer une valeur par défaut. Un validateur qui interprète l'absence d'exp comme « pas d'expiration » transforme un seul token divulgué en accès permanent. Effectuez toutes les vérifications avant que la moindre logique métier ne touche la requête, et renvoyez un 401 avec un en-tête WWW-Authenticate: Bearer error="invalid_token" comme le prévoit la RFC 6750, en restant vague sur la raison auprès de l'appelant et précis dans vos logs.
Tokens stateless et problème de la révocation
La propriété qui rend les tokens autoporteurs séduisants — n'importe quel service peut en valider un sans interroger l'émetteur — est exactement celle qui rend la révocation difficile. Tant qu'il n'a pas expiré, un token valide reste valide, même si le compte a été désactivé il y a une minute. Quatre réponses viables, généralement combinées :
- Des durées de vie courtes. Cinq à quinze minutes pour un access token bornent l'exposition et constituent la mitigation la moins coûteuse. Associez-les à la rotation des refresh tokens pour que l'expérience utilisateur reste fluide.
- Une deny list. Stockez le
jtides tokens révoqués jusqu'à leurexpet vérifiez-le à chaque requête. La liste reste petite et bornée, puisque les entrées expirent d'elles-mêmes, mais on réintroduit une consultation partagée. - Un claim de version de token. Conservez un entier par utilisateur, incluez-le dans les tokens émis et comparez-le à une valeur en cache. L'incrémenter invalide en une seule écriture tout ce qui a été émis pour cet utilisateur — idéal pour « se déconnecter partout » et pour les changements de mot de passe.
- L'introspection. Interrogez l'authorization server à chaque appel, éventuellement avec un cache de quelques secondes. C'est la méthode la plus précise, au prix d'une dépendance dans le chemin critique ; une API gateway est un endroit judicieux pour le faire une seule fois pour de nombreux services.
Les opérations sensibles méritent de toute façon un traitement renforcé : réauthentification, contrôle d'auth_time ou exigence d'un token avec preuve de possession avant un virement ou un changement de permissions.
Bearer tokens, stockage et transport
Un bearer token est un titre au porteur : quiconque le détient peut l'utiliser. Cette seule phrase dicte l'essentiel des règles opérationnelles. Ne l'envoyez qu'en TLS 1.3 (ou 1.2 avec des suites de chiffrement modernes), uniquement dans l'en-tête Authorization — jamais dans une query string, où il finit dans les logs d'accès, les caches des proxys et les en-têtes Referer — et ne le journalisez jamais, pas même tronqué dans un build de debug.
Pour les clients navigateur, la question du stockage n'a pas de réponse gratuite :
| Emplacement | Exposition | Remarques |
|---|---|---|
localStorage | N'importe quel script de la page peut le lire | Une faille XSS équivaut au vol complet du token ; persiste entre onglets et redémarrages |
| Variable en mémoire | Les scripts de la page, tant qu'elle est chargée | Perdue au rechargement de la page ; mieux que la persistance, mais toujours atteignable par XSS |
Cookie HttpOnly | Illisible par les scripts | Envoyé automatiquement, donc des défenses CSRF sont indispensables |
| Backend-for-frontend | Absent du navigateur | Les tokens restent côté serveur ; le navigateur détient un cookie de session |
Le pattern backend-for-frontend — un petit composant côté serveur qui effectue l'échange du code, conserve les tokens et relaie les appels d'API — est la recommandation pour les nouvelles applications navigateur. Lorsque le token doit vivre dans le navigateur, gardez-le en mémoire, avec une durée de vie courte, et classez le cross-site scripting en gravité critique : avec un token dans la page, c'en est bien une.
Les sender-constrained tokens éliminent purement et simplement le problème du porteur. Avec DPoP (RFC 9449), le client génère une paire de clés et envoie un en-tête de preuve signé avec chaque requête ; le token porte une empreinte de la clé publique, si bien qu'un token volé sans la clé privée est inutilisable. La liaison à un certificat mutual-TLS (RFC 8705) obtient le même résultat pour les clients côté serveur. Les deux en valent la peine pour les API à forte valeur.
GET /accounts/1042/transactions HTTP/1.1
Host: api.example.com
Authorization: DPoP eyJhbGciOiJFUzI1NiIsInR5cCI6ImF0K2p3dCJ9...
DPoP: eyJ0eXAiOiJkcG9wK2p3dCIsImFsZyI6IkVTMjU2Iiwiandr...
Ce que le token ne fait pas
L'authentification vous dit qui appelle. Tout ce qui suit reste de votre responsabilité, et c'est là que se produisent réellement la plupart des brèches d'API.
Un scope n'est pas une autorisation. Un token portant orders.read indique que le client peut tenter de lire des commandes. Savoir si ce sujet peut lire la commande 1042 est une question à laquelle seul votre domaine peut répondre, et l'oublier, c'est la faille dite broken object-level authorization — la faille d'API grave la plus répandue. Ne déduisez jamais la propriété d'un identifiant présent dans le chemin ou le corps ; déduisez-la de sub et vérifiez.
@GetMapping("/orders/{id}")
@PreAuthorize("hasAuthority('SCOPE_orders.read')")
public OrderView get(@PathVariable long id, @AuthenticationPrincipal Jwt jwt) {
Order order = orders.findById(id).orElseThrow(NotFoundException::new);
if (!order.customerId().equals(jwt.getSubject())) {
throw new NotFoundException(); // 404 et non 403 : ne pas confirmer que l'enregistrement existe
}
return OrderView.of(order);
}
Validez chaque entrée. Types, plages, longueurs, énumérations et formats, côté serveur, en rejetant plutôt qu'en nettoyant chaque fois que c'est possible. Plafonnez la taille des corps de requête et la longueur des collections pour qu'un seul appel ne puisse pas épuiser la mémoire, et imposez des limites à tout parser que vous exposez.
Appliquez un rate limiting par identité, pas seulement par adresse. Des limites indexées sur le sujet ou l'identifiant du client résistent aux proxys partagés et aux réseaux mobiles ; des limites par endpoint protègent les opérations coûteuses ; un plafond global protège le service. Renvoyez 429 avec Retry-After.
Ne laissez rien fuiter par les erreurs ou par la forme des réponses. Une stack trace, un message distinct pour « utilisateur inconnu » et « mot de passe incorrect », ou un 403 là où un 404 en révélerait moins : tout cela donne des informations à un attaquant. Les choix de codes de statut qui en découlent sont abordés dans le guide plus général sur la conception d'API REST.
Restreignez CORS délibérément. Refléter l'en-tête Origin tout en autorisant les credentials revient à n'avoir aucune politique ; énumérez les origines que vous servez réellement.
Journalisez les décisions, pas les tokens. Enregistrez le sujet, le client, l'endpoint, le résultat et l'identifiant de corrélation. C'est cette trace qui permet de reconstituer un incident, et elle ne coûte rien au moment de la requête.
Points clés à retenir
- Un JWT signé est lisible par quiconque le détient ; la signature apporte intégrité et origine, pas confidentialité.
- Figez les algorithmes acceptés dans le code et choisissez les clés par
kiddans un JWKS en cache — jamais d'après les champsalg,jkuoujwkdu token. - Préférez les signatures asymétriques, pour qu'un resource server puisse vérifier sans pouvoir émettre de tokens.
- Validez explicitement
iss,aud,expetnbf, et rejetez les claims absents au lieu de leur donner une valeur par défaut. - Un token stateless ne peut pas être retiré de la circulation : combinez des durées de vie courtes avec une deny list, un claim de version de token ou l'introspection quand la révocation immédiate compte.
- Gardez les tokens du navigateur hors du stockage persistant — préférez un backend-for-frontend — et envisagez DPoP ou une liaison mTLS pour les API à forte valeur.
- Les scopes ne remplacent jamais l'autorisation au niveau des ressources, la validation des entrées, le rate limiting ni des réponses d'erreur soignées.
Sources et lectures complémentaires
Questions fréquentes
- Un JWT est-il chiffré ?
- Un JWT signé ne l'est pas. L'en-tête et le payload sont encodés en base64url, ce qui est un encodage et non un chiffrement : quiconque détient le token peut lire chaque claim. La signature garantit l'intégrité et l'origine, rien de plus. Si un claim doit rester confidentiel vis-à-vis du client ou des intermédiaires, retirez-le purement et simplement du token ou utilisez JWE, qui chiffre le payload.
- Comment révoquer un JWT avant son expiration ?
- Le token lui-même ne se révoque pas, puisque la vérification se fait hors ligne par conception. Les approches pratiques sont des durées de vie courtes pour réduire la fenêtre d'exposition, une deny list d'identifiants de tokens consultée à chaque requête, une version de token par utilisateur comparée à un claim, ou l'introspection auprès de l'émetteur. La plupart des systèmes combinent des access tokens courts et la révocation du refresh token.
- Faut-il mettre le token dans localStorage ou dans un cookie ?
- Aucune des deux options n'est juste dans l'absolu. localStorage est lisible par n'importe quel script : une seule faille de cross-site scripting suffit à exfiltrer le token. Un cookie peut être HttpOnly et invisible des scripts, mais il est envoyé automatiquement, ce qui réintroduit le cross-site request forgery si vous ne positionnez pas SameSite et n'utilisez pas un token anti-CSRF. L'option la plus robuste est un backend-for-frontend qui garde les tokens côté serveur et donne au navigateur un cookie de session.
- Pourquoi l'algorithme symétrique HS256 est-il risqué pour une API publique ?
- Il utilise un seul secret partagé pour signer et pour vérifier : tout service capable de vérifier un token peut donc aussi en fabriquer. Dans un système comptant plusieurs resource servers, n'importe quel service compromis ou négligent devient une fabrique de tokens. Une signature asymétrique en RS256 ou ES256 ne donne aux vérificateurs que la clé publique, si bien qu'un resource server compromis ne peut pas forger de tokens pour ses voisins.