Aller au contenu

Accueil / Sécurité et identité

Entra ID: refresh token, rejeu et révocation

Comment Entra ID émet, renouvelle et révoque un refresh token: durée glissante ou absolue, rejeu d'un ancien jeton, codes AADSTS et détection.

Rémi Vernier
9 novembre 2026 · 8 min de lecture

Dans la vie d’un tenant Entra ID, ce moment arrive tôt ou tard: un collaborateur signale le vol de son portable, le support réinitialise son mot de passe, et pourtant une application continue d’accéder à sa messagerie pendant encore quelques minutes. L’équipe sécurité veut alors savoir ce qui a survécu au changement de mot de passe, et pour combien de temps.

La réponse tient presque toujours dans deux jetons. Le jeton d’accès vit peu de temps. Le refresh token vit longtemps et permet à l’application d’obtenir de nouveaux jetons d’accès sans rien redemander à l’utilisateur. Nous allons suivre un refresh token de son émission à sa révocation, en appelant directement le point de terminaison /token et Microsoft Graph, et en lisant les codes AADSTS qu’Entra ID renvoie à chaque refus.

Présentation de la maquette

Tenant: testlocal.onmicrosoft.com

Utilisateur: user01@testlocal.onmicrosoft.com

Application: app-monsite, enregistrée comme client public (redirection http://localhost), avec la permission déléguée User.Read sur Graph

Outils: curl et jq sur un poste d’administration, ainsi que le module Microsoft Graph PowerShell pour la révocation.

Attention: les durées citées plus bas sont celles de la documentation Microsoft sur les refresh tokens. Ce sont des valeurs par défaut, qu’une stratégie d’accès conditionnel peut raccourcir. Sur un tenant de production, il faut donc vérifier ce qui s’applique réellement avant de bâtir une procédure dessus.

Un jeton que nous ne pouvons pas lire

Le jeton d’accès est un JWT que nous pouvons décoder en base64, comme nous l’avions fait pour l’entête Authorization dans l’article sur les entêtes HTTP WWW-Authenticate et Authorization. Le refresh token Entra ID, lui, est chiffré et seule la plateforme d’identité Microsoft peut le lire. Il n’y a donc pas de claim exp à inspecter ni de date d’expiration à extraire. Ce que nous pouvons lire, c’est la réponse du point de terminaison /token, et surtout le message d’erreur qu’il renvoie quand il refuse le jeton.

Le premier refresh token (appelons-le RT1) est émis lors de l’échange du code d’autorisation, à condition que le scope offline_access ait été demandé. Nous le présentons ensuite au point de terminaison avec le grant refresh_token:

shell
curl -s https://login.microsoftonline.com/testlocal.onmicrosoft.com/oauth2/v2.0/token \
  -d client_id=$CLIENT_ID \
  -d grant_type=refresh_token \
  --data-urlencode refresh_token=$RT1 \
  --data-urlencode "scope=https://graph.microsoft.com/User.Read offline_access" \
  | jq 'del(.access_token, .refresh_token)'

Nous retirons volontairement les deux jetons de l’affichage. La réponse prend cette forme:

json
{
  "token_type": "Bearer",
  "scope": "https://graph.microsoft.com/User.Read",
  "expires_in": 4732,
  "ext_expires_in": 4732
}

-> expires_in concerne le jeton d’accès seulement, et rien dans la réponse ne donne la durée de vie du nouveau refresh token.

Le champ refresh_token contient un nouveau jeton, RT2. Un refresh token est remplacé par un nouveau à chaque utilisation. Par défaut, il vit 90 jours dans tous les scénarios, sauf quand il est envoyé à une URI de redirection de type SPA: dans ce cas il expire au bout de 24 heures, et les jetons obtenus ensuite à partir de lui héritent de cette échéance.

C’est toute la différence entre les deux modèles. Pour une application web, mobile ou de bureau, la fenêtre glisse: chaque utilisation produit un jeton neuf, et un client utilisé régulièrement peut se passer de toute nouvelle saisie pendant des mois. Pour une SPA, l’échéance est absolue et l’application doit refaire un flux interactif toutes les 24 heures, quelle que soit son activité. Une stratégie d’accès conditionnel avec une fréquence de connexion ajoute une limite absolue par-dessus ces deux modèles. Les stratégies de durée de vie configurables ne s’appliquent plus aux refresh tokens depuis 2021: c’est l’accès conditionnel qui a pris le relais.

Chaque fin de vie a son code d’erreur, toujours renvoyé avec "error": "invalid_grant":

Situation Nature de la limite Code renvoyé
Jeton inutilisé trop longtemps (client non SPA) Glissante AADSTS700082
Jeton émis pour une SPA, 24 heures écoulées Absolue AADSTS700084
Fréquence de connexion de l’accès conditionnel atteinte Absolue AADSTS70043
Sessions révoquées ou mot de passe changé Événement AADSTS50173

Rejeu et révocation

Si nous rejouons RT1 alors que RT2 a déjà été émis, la plateforme d’identité Microsoft ne révoque pas les anciens refresh tokens quand ils servent à obtenir de nouveaux jetons d’accès. La documentation demande à l’application de supprimer elle-même l’ancien jeton. RT1 et RT2 restent donc utilisables tous les deux, et rejouer RT1 nous donne un RT3 parfaitement valide. Le remplacement à chaque utilisation existe bien, mais aucune détection de réutilisation ne vient avec. Ce point est très important pour la suite: la RFC 9700 (OAuth 2.0 Security Best Current Practice) demande que les refresh tokens des clients publics soient soit liés à leur détenteur, soit soumis à une rotation qui permet de repérer un jeton déjà utilisé.

Ce qui arrête toute la chaîne, c’est la révocation côté annuaire. L’appel Graph revokeSignInSessions invalide tous les refresh tokens émis pour l’utilisateur, ainsi que les cookies de session de son navigateur. Pour cela, il fixe la propriété signInSessionsValidFromDateTime à l’heure courante. En PowerShell, l’équivalent est Revoke-MgUserSignInSession, avec au minimum la permission User.RevokeSessions.All:

code
Connect-MgGraph -Scopes "User.RevokeSessions.All","User.Read.All"
Revoke-MgUserSignInSession -UserId user01@testlocal.onmicrosoft.com

Get-MgUser -UserId user01@testlocal.onmicrosoft.com `
  -Property signInSessionsValidFromDateTime |
  Select-Object SignInSessionsValidFromDateTime

La documentation prévient qu’il peut s’écouler quelques minutes avant que les jetons soient réellement révoqués. Une fois ce délai passé, le rejeu de RT1, RT2 ou RT3 donne la même réponse:

json
{
  "error": "invalid_grant",
  "error_description": "AADSTS50173: The provided grant has expired due to it being revoked, a fresh auth token is needed. The user might have changed or reset their password. The grant was issued on '2026-11-09T08:12:41.0000000Z' and the TokensValidFrom date (before which tokens are not valid) for this user is '2026-11-09T09:30:05.0000000Z'.",
  "error_codes": [50173]
}

Le message contient les deux dates qui comptent: la date d’émission du grant d’origine et la nouvelle borne TokensValidFrom. Tout jeton dont la chaîne remonte à une authentification antérieure à cette borne est refusé, ce qui explique pourquoi la date du premier échange reste attachée aux jetons qui en descendent.

Client app-monsite Entra ID /token grant_type=refresh_token (RT1) jeton d’accès + RT2 (RT1 reste valide) rejeu de RT1 jeton d’accès + RT3 revokeSignInSessions signInSessionsValidFromDateTime = maintenant RT1, RT2 ou RT3 invalid_grant AADSTS50173
Remplacement sans invalidation, puis révocation par la borne signInSessionsValidFromDateTime

Attention, la révocation ne touche pas les jetons d’accès déjà émis. Un jeton d’accès reste accepté par la ressource jusqu’à son expiration (une heure ou un peu plus par défaut, d’où les 4732 secondes vues plus haut), sauf si la ressource prend en charge l’évaluation continue de l’accès (CAE). Exchange Online, SharePoint Online et Teams reçoivent alors l’événement de révocation et rejettent le jeton presque immédiatement. Les minutes de délai observées par le support dans notre histoire d’introduction viennent de là.

Autre piège en environnement multi-tenant: les refresh tokens d’un invité B2B ne sont pas révoqués dans le tenant de ressource. La révocation doit se faire dans son tenant d’origine.

Ce qu’un attaquant en fait

Le scénario de compromission se résume en quelques étapes. L’attaquant récupère un refresh token, soit par un proxy de phishing de type adversary-in-the-middle qui relaie toute la connexion, MFA compris, soit par un infostealer qui vide le cache d’une application ou d’un navigateur sur le poste. Il le présente ensuite depuis sa propre infrastructure. MITRE ATT&CK range ces deux temps sous T1528 (Steal Application Access Token) pour le vol et T1550.001 (Use Alternate Authentication Material: Application Access Token) pour l’usage. L’authentification forte a déjà eu lieu lors de la connexion d’origine, et le jeton en garde le bénéfice. Sur un client non SPA, la fenêtre glissante permet à l’attaquant de prolonger l’accès aussi longtemps qu’il utilise le jeton, et ce que fait la victime ne l’en empêche pas, puisque ses propres renouvellements n’invalident rien.

Le rayon d’impact dépasse la ressource d’origine. Un refresh token est lié à un couple utilisateur et client, mais pas à une ressource ni à un tenant. Avec un jeton obtenu pour Graph, nous pouvons donc demander des jetons d’accès pour toute autre ressource que ce client a le droit d’atteindre pour cet utilisateur. Plus l’application cliente a de permissions consenties, plus le jeton volé ouvre de portes. Les jetons de première partie Microsoft, émis pour des clients très largement autorisés, sont les plus intéressants pour un attaquant. Le refresh token du navigateur et des applications natives sur un poste joint s’appuie de son côté sur le PRT, dont nous avions détaillé la mécanique dans l’anatomie du Primary Refresh Token.

La détection est difficile, car un rejeu ressemble à un renouvellement silencieux légitime. Il n’apparaît pas dans les connexions interactives, aucune demande MFA n’est déclenchée, et il se retrouve noyé dans le volume énorme des journaux de connexions non interactives. Le signal le plus exploitable est l’incohérence réseau au sein d’une même session: un même SessionId utilisé depuis des adresses ou des pays différents dans un court intervalle. Dans Log Analytics, une première requête de chasse peut ressembler à ceci:

code
AADNonInteractiveUserSignInLogs
| where TimeGenerated > ago(1d)
| where IncomingTokenType == "refreshToken"
| summarize IPs = make_set(IPAddress), Lieux = make_set(Location),
            Debut = min(TimeGenerated), Fin = max(TimeGenerated)
          by UserPrincipalName, SessionId, AppDisplayName
| where array_length(IPs) > 1 and array_length(Lieux) > 1

Les résultats restent à qualifier: un utilisateur nomade ou une sortie internet qui change d’adresse produit le même motif. C’est en corrélant avec l’ASN, la conformité de l’appareil (DeviceDetail) et les détections d’Entra ID Protection, comme le jeton anormal, que nous écartons les faux positifs.

Côté durcissement, la procédure de réponse doit suivre le bon ordre: désactiver le compte, révoquer les sessions, réinitialiser le mot de passe, puis vérifier dans les journaux non interactifs qu’aucun renouvellement réussi n’apparaît après la borne signInSessionsValidFromDateTime. Si nous révoquons sans désactiver le compte alors que l’attaquant contrôle encore le point d’entrée (le proxy de phishing ou le poste infecté), il récupère simplement un nouveau jeton à la connexion suivante de la victime.

En prévention, je recommande de commencer par la protection des jetons (Token Protection) de l’accès conditionnel. Elle lie le jeton de session à l’appareil qui l’a obtenu, ce qui rend le rejeu depuis une autre machine inopérant. Sa couverture reste limitée à certaines plateformes et applications, et il faut donc vérifier dans la documentation lesquelles sont prises en charge au moment du déploiement. Sur les applications sensibles, une fréquence de connexion courte transforme la fenêtre glissante en limite absolue. Pour les applications développées en interne, la documentation demande aussi de supprimer l’ancien refresh token dès qu’un nouveau est obtenu: Entra ID ne le fait pas, et c’est donc le stockage côté client qui détermine combien de jetons valides traînent sur un poste.

Rémi Vernier

Rémi Vernier

Responsable des opérations cybersécurité dans un grand groupe international. Identité, Active Directory, Entra ID, cloud hybride, et désormais SAP, IA et modèles en local.

Tous les articles → LinkedIn →

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *