Comment une API vérifie un access token JWT
Algorithme, kid, JWKS, signature, issuer, audience, expiration : ce qu’une API contrôle, et dans quel ordre, avant de croire le contenu d’un access token JWT.
- Identity Security
- JWT
- OIDC
Dans l’article sur PKCE, le flux s’arrêtait au moment où l’application récupère ses tokens. L’access token part ensuite vers l’API, dans un en-tête Authorization: Bearer.
L’API reçoit donc une chaîne de caractères. Avant d’en tirer une identité ou un rôle, elle doit établir trois choses : cette chaîne a été émise par le bon Identity Provider, elle lui est destinée, et elle est encore valable. Cet article détaille ces contrôles dans l’ordre où une bibliothèque les applique.
Le problème
Un JWT se lit sans aucune clé. Ses trois parties sont simplement encodées en base64url, sans chiffrement :
en-tête . charge utile . signature
N’importe qui peut décoder la charge utile et y lire sub, aud ou realm_access.roles. N’importe qui peut aussi en fabriquer une, avec les rôles de son choix.
La tentation, côté API, est de décoder le jeton et d’utiliser directement ce qu’il contient. Une API qui procède ainsi accorde les droits que l’appelant a décidé de s’attribuer.
La vraie question est donc :
Qu’est-ce qui me prouve que ce contenu
a été écrit par mon Identity Provider, pour moi ?
Le modèle mental
On peut se représenter la vérification comme un guichet qui contrôle un document officiel. L’agent regarde d’abord le tampon, puis à qui le document est adressé, puis sa date de validité. Le nom inscrit dessus ne compte qu’après.
Pour un JWT, cela donne deux étapes bien séparées :
1. Le document est-il authentique ?
algorithme attendu → clé (kid, JWKS) → signature
2. Est-il valable ici et maintenant ?
iss → aud → exp
Seulement ensuite : sub, rôles, email…
La première étape repose sur la cryptographie. La signature a été produite avec la clé privée de l’Identity Provider, et l’API la vérifie avec la clé publique correspondante. La seconde repose sur les claims : la signature les protège contre la modification, mais ne dit rien de leur sens.
Un jeton correctement signé peut très bien venir du bon émetteur, être destiné à une autre API et avoir expiré depuis une heure.
Comment ça fonctionne
1. Lire l’en-tête sans lui faire confiance
L’en-tête annonce deux informations utiles :
{ "alg": "RS256", "typ": "JWT", "kid": "6_AoHDyRyr1VqL_IykD0xBA0Ukq9rD2iu0N-rrvwXjE" }
alg indique l’algorithme de signature, kid l’identifiant de la clé utilisée.
Ces valeurs sont écrites par celui qui présente le jeton. L’API ne doit donc jamais laisser alg choisir l’algorithme de vérification : elle fixe elle-même la liste des algorithmes acceptés et rejette le reste. Plusieurs bibliothèques ont accepté par le passé alg: none, ou une signature HMAC calculée avec la clé publique RSA en guise de secret. La RFC 8725, qui rassemble les bonnes pratiques JWT, demande explicitement de restreindre les algorithmes.
2. Trouver la clé publique
L’Identity Provider publie ses clés publiques dans un JWKS, un JSON Web Key Set défini par la RFC 7517. Son adresse figure dans le document de découverte OIDC, sous jwks_uri.
L’API cherche dans ce jeu la clé dont le kid correspond à celui de l’en-tête. Un JWKS contient souvent plusieurs clés, par exemple l’ancienne et la nouvelle pendant une rotation. Le kid évite de les essayer une à une.
Télécharger le JWKS à chaque requête serait lent et ferait de l’Identity Provider un point de passage obligé. Les bibliothèques le mettent donc en cache, et le rechargent quand un kid inconnu apparaît.
3. Vérifier la signature
Avec la clé trouvée et l’algorithme imposé, l’API vérifie la signature sur en-tête.charge utile. Si un seul caractère a changé, rôles compris, la vérification échoue.
Cette étape est locale. L’API n’appelle pas l’Identity Provider pour savoir si le jeton est bon, elle le vérifie seule avec une clé publique déjà en cache. C’est ce qui rend les JWT rapides, et c’est aussi ce qui empêche de les révoquer avant leur expiration.
4. Vérifier l’émetteur
iss doit correspondre exactement à l’issuer attendu, par exemple :
http://localhost:8080/realms/identity-lab
C’est une comparaison de chaînes. Un slash final, http à la place de https ou un nom d’hôte différent suffisent à faire échouer le contrôle.
5. Vérifier l’audience
aud répond à la question : pour qui ce jeton a-t-il été émis ? L’API vérifie que son propre identifiant y figure. aud peut être un tableau, et le contrôle passe si la valeur attendue en fait partie.
Sans ce contrôle, un access token obtenu pour une autre application du même Identity Provider serait accepté. C’est aussi lui qui écarte un ID token présenté par erreur à l’API : l’audience d’un ID token est le client_id de l’application.
6. Vérifier la fenêtre de validité
exp fixe le moment à partir duquel le jeton est refusé, nbf celui avant lequel il ne l’est pas encore. Les deux sont exprimés en secondes depuis 1970 et comparés à l’horloge du serveur qui vérifie.
7. Utiliser les claims
C’est seulement à ce stade que l’API lit sub, les rôles ou l’email. Elle sait désormais que ces valeurs ont été écrites par l’Identity Provider, pour elle, et qu’elles sont dans leur fenêtre de validité.
Elle ne sait pas encore si l’utilisateur a le droit de faire ce qu’il demande. C’est le sujet de Pourquoi un jeton valide ne suffit pas à autoriser une requête.
Exemple concret
Trois jetons arrivent sur la même API.
Le premier a été collé dans un outil de décodage, puis modifié à la main pour ajouter le rôle admin. Son en-tête et sa charge utile restent parfaitement lisibles, mais la signature a été calculée sur l’ancienne charge utile. La vérification échoue à l’étape 3.
Le deuxième est authentique, mais il vient d’un autre realm du même serveur Keycloak. Chaque realm a ses propres clés : son kid est absent du JWKS attendu, et le refus tombe à l’étape 2, avant même que l’issuer soit regardé.
Le troisième vient du bon realm et vise la bonne API, mais il a été conservé dix minutes alors qu’il vivait cinq minutes. Signature, issuer et audience passent. L’expiration le refuse.
Les trois produisent un 401, pour trois causes différentes. Une API qui les distingue dans ses journaux fait gagner beaucoup de temps au diagnostic.
Dans mon lab
Dans mon Identity Security Lab, l’API Express valide les access tokens émis par Keycloak avec la bibliothèque jose. L’essentiel tient en quelques lignes :
const JWKS = createRemoteJWKSet(
new URL(`${env.keycloakIssuer}/protocol/openid-connect/certs`),
);
const { payload } = await jwtVerify(token, JWKS, {
issuer: env.keycloakIssuer,
audience: env.keycloakAudience,
algorithms: ["RS256"],
});
algorithms: ["RS256"] fixe l’algorithme côté API, quel que soit le contenu de l’en-tête.
L’adresse du JWKS est construite à la main à partir de l’issuer. L’API n’utilise pas la découverte .well-known, que seul le frontend exploite à travers Auth.js. C’est plus simple, au prix d’une dépendance au format des chemins de Keycloak.
jose applique les contrôles dans l’ordre décrit plus haut : algorithme, clé, signature, puis claims. J’ai gardé ses paramètres de cache par défaut : le JWKS est conservé dix minutes, et un kid inconnu déclenche un rechargement au plus toutes les trente secondes.
Après jwtVerify, l’API vérifie encore la présence de sub, puis ne retient des rôles Keycloak que user, manager et admin. Keycloak en ajoute d’autres, techniques, dont l’application n’a pas l’usage.
Un message par cause
Chaque erreur de jose est traduite en un code et un message distincts. Ces refus ont été provoqués en appelant directement l’API :
En-tête absent En-tête Authorization: Bearer <token> attendu.
Chaîne quelconque Le jeton est mal formé.
Signature altérée Signature invalide. Le jeton n’a pas été émis par Keycloak.
Realm master Aucune clé du JWKS ne correspond au kid du jeton.
Jeton expiré Le jeton a expiré.
Le cas du realm master est contre-intuitif : on attendrait un refus sur l’issuer. Il tombe sur le kid, parce que la recherche de la clé précède la lecture des claims.
Le jeton expiré a été présenté 247 secondes après son exp. Un access token du lab vit 300 secondes.
Chaque refus laisse une trace d’audit avec la cause, la route et l’adresse de l’appelant, mais jamais le jeton ni l’en-tête Authorization. Un journal d’audit est consulté largement, y déposer des jetons en ferait une cible.
Ce que je n’ai pas testé
Je n’ai pas provoqué d’échec sur l’audience, qui demanderait un second client dans Keycloak. Le contrôle est vérifié par le code et par la valeur réelle d’aud dans les jetons du lab :
{ "aud": ["identity-lab-api", "account"], "azp": "identity-lab-web" }
identity-lab-api n’y figure que grâce à un mapper d’audience ajouté sur le client identity-lab-web. Par défaut, Keycloak ne met que account. Sans ce mapper, l’API aurait le choix entre refuser tous les jetons et attendre account, que porte n’importe quel jeton du realm. Le contrôle deviendrait décoratif.
Une limite réelle de ma configuration
jose ne vérifie exp que si le claim est présent. Pour en faire une exigence, il faut le demander :
requiredClaims: ["exp"]
Mon API ne le fait pas. Keycloak pose toujours exp, donc aucun jeton sans expiration n’arrive en pratique, mais l’API dépend d’un comportement de l’émetteur au lieu de l’imposer. C’est la même situation que pour PKCE dans l’article précédent : la protection fonctionne, sans être exigée côté serveur.
Ce qui peut mal tourner
Décoder au lieu de vérifier
Une fonction qui décode le jeton, comme jwt-decode, lit son contenu sans rien prouver. Elle a sa place pour afficher un nom à l’écran, jamais pour prendre une décision d’accès.
Oublier l’audience
Symptôme : aucun. Tout fonctionne, et un jeton émis pour une autre application du même realm est accepté lui aussi. C’est le contrôle dont l’absence ne se remarque jamais en test.
Confondre ID token et access token
Les deux sont des JWT signés par la même clé. La RFC 9068 propose de marquer les access tokens avec typ: at+jwt pour les distinguer. Les jetons de mon lab portent typ: JWT, l’API ne peut donc pas s’appuyer sur ce champ. C’est l’audience qui fait la différence.
Une horloge qui dérive
exp est comparé à l’heure du serveur qui vérifie. Une API dont l’horloge avance de quelques minutes refuse des jetons encore valides, une API en retard accepte des jetons expirés.
Dans mon lab, aucune tolérance n’est configurée : un jeton est refusé dès la seconde de son exp. jose permet d’accorder quelques secondes de marge avec clockTolerance. La vraie correction reste la synchronisation NTP du serveur.
Un JWKS injoignable
La connexion fonctionne, l’utilisateur arrive sur l’application, puis chaque appel d’API répond 401. Le login passe par le navigateur et le token endpoint, la vérification passe par le JWKS : ce sont deux chemins réseau différents.
Ce cas mérite un article à lui seul, avec le cache, la rotation des clés et le nouveau kid absent du cache.
À retenir
- Un JWT se lit sans clé. Le lire ne prouve rien.
- L’API fixe elle-même les algorithmes acceptés et ignore celui que propose l’en-tête.
- Le
kiddésigne la clé à chercher dans le JWKS de l’Identity Provider, que l’API garde en cache. - La signature prouve l’origine et l’intégrité. Elle ne dit rien de l’audience ni de la date.
iss,audetexpse vérifient ensuite, à chaque requête.- Les claims ne servent qu’une fois tous ces contrôles passés.
- Un message de refus par cause transforme un 401 opaque en diagnostic.
- Dans mon lab,
josefait l’essentiel. Exigerexpexplicitement serait le prochain durcissement.
Un jeton qui passe tous ces contrôles est authentique, destiné à l’API et encore valable. Il ne dit toujours pas si l’action demandée est permise : c’est là que commence l’autorisation.