{
  "schemaVersion": "1.0",
  "code": "70008",
  "name": "ExpiredOrRevokedGrant",
  "title": "AADSTS70008: The refresh token has expired or was revoked (fix)",
  "url": "https://brambrinkmeier.com/entra/aadsts/70008/",
  "alternates": {
    "markdown": "https://brambrinkmeier.com/entra/aadsts/70008.md",
    "json": "https://brambrinkmeier.com/api/aadsts/70008.json",
    "html": "https://brambrinkmeier.com/entra/aadsts/70008/"
  },
  "description": "AADSTS70008: a refresh token expired from inactivity or was revoked. Usually normal token lifecycle with low attack value; the app must prompt. KQL.",
  "summary": "AADSTS70008 means an app tried to use a refresh token or grant that is no longer valid. Microsoft's text names inactivity; the code name also says revoked. It is usually part of the normal token lifecycle and has low attack value. The app should send the user to an interactive sign-in. Hunt it in the non-interactive sign-in logs.",
  "inMicrosoftReference": true,
  "unlistedChecked": null,
  "meaningText": "Microsoft's description:\n\n> **ExpiredOrRevokedGrant**: The refresh token has expired due to inactivity. The token was issued on XXX and was inactive for a certain amount of time.\n\nAn app presented a refresh token (a \"grant\") to get a new access token, and Microsoft Entra ID refused it. The description talks about inactivity. The code name, *ExpiredOrRevokedGrant*, also covers revocation. Microsoft doesn't say which cases return 70008 rather than a more specific code.\n\nTwo sibling codes in the same reference table are more specific:\n\n- [700082](https://brambrinkmeier.com/entra/aadsts/700082/) **ExpiredOrRevokedGrantInactiveToken**, with almost the same text, which Microsoft calls an *\"Expected part of the token lifecycle\"*.\n- [50173](https://brambrinkmeier.com/entra/aadsts/50173/), a grant revoked after a credential change: *\"The user might have changed or reset their password.\"*\n\nOur reading: 70008 is the more generic form of the same event. Treat it like 700082 unless the context points to revocation.\n\n**How long refresh tokens live.** Microsoft's refresh token page gives the defaults: *\"24 hours for single-page applications\"*, 24 hours for the email one-time passcode flow, and *\"90 days for all other scenarios.\"* The configurable token lifetimes page lists a non-configurable *Refresh Token Max Inactive Time* of 90 days. Tokens *\"can be revoked by the sign-in service at any time before their expiration\"*, for example when the user changes their password or an admin revokes all refresh tokens for the user.",
  "sections": [
    {
      "heading": "What does AADSTS70008 mean?",
      "text": "Microsoft's description:\n\n> **ExpiredOrRevokedGrant**: The refresh token has expired due to inactivity. The token was issued on XXX and was inactive for a certain amount of time.\n\nAn app presented a refresh token (a \"grant\") to get a new access token, and Microsoft Entra ID refused it. The description talks about inactivity. The code name, *ExpiredOrRevokedGrant*, also covers revocation. Microsoft doesn't say which cases return 70008 rather than a more specific code.\n\nTwo sibling codes in the same reference table are more specific:\n\n- [700082](https://brambrinkmeier.com/entra/aadsts/700082/) **ExpiredOrRevokedGrantInactiveToken**, with almost the same text, which Microsoft calls an *\"Expected part of the token lifecycle\"*.\n- [50173](https://brambrinkmeier.com/entra/aadsts/50173/), a grant revoked after a credential change: *\"The user might have changed or reset their password.\"*\n\nOur reading: 70008 is the more generic form of the same event. Treat it like 700082 unless the context points to revocation.\n\n**How long refresh tokens live.** Microsoft's refresh token page gives the defaults: *\"24 hours for single-page applications\"*, 24 hours for the email one-time passcode flow, and *\"90 days for all other scenarios.\"* The configurable token lifetimes page lists a non-configurable *Refresh Token Max Inactive Time* of 90 days. Tokens *\"can be revoked by the sign-in service at any time before their expiration\"*, for example when the user changes their password or an admin revokes all refresh tokens for the user."
    },
    {
      "heading": "Is it an attack?",
      "text": "Usually not. A refresh token that expired or was revoked gives nobody access. The common sources are a device or app that wasn't used for a long time, and an app that keeps an old token after a password change or a session revocation.\n\n| Pattern | Likely meaning |\n|---|---|\n| 70008 from a user's known device, then an interactive success | The user came back after a long break, or after a password change, and signed in again. Benign. |\n| 70008 for many users of one app, all at once | Our interpretation: a mass revocation (for example after an incident response) or an app holding tokens far past their lifetime. Check what happened that day. |\n| 70008 shortly after you revoked a user's sessions | The revocation is working. The old token was refused. |\n| 70008 from an IP or device the user doesn't normally use | Someone presented an old refresh token from an unfamiliar place. Reasoning, not a documented detection: if the token was stolen, expiry or revocation is what stopped it. Worth a short look. |\n\n**IP caveat.** Microsoft's non-interactive sign-ins page says the IP of non-interactive sign-ins by confidential clients *\"doesn't match the actual source IP of where the refresh token request is coming from. Instead, it shows the original IP used for the original token issuance.\"* An unfamiliar IP on a 70008 row is a meaningful signal mainly for public clients (desktop, mobile and browser apps)."
    },
    {
      "heading": "How do I find it in my logs (KQL)?",
      "text": "Refresh token redemption is a non-interactive sign-in (*\"A client app uses an OAuth 2.0 refresh token to get an access token\"* is one of Microsoft's examples), so expect most rows in `AADNonInteractiveUserSignInLogs`. Microsoft doesn't document the table for this code specifically, so check:\n\n```kusto\nunion withsource=SourceTable SigninLogs, AADNonInteractiveUserSignInLogs\n| where TimeGenerated > ago(7d)\n| where ResultType == \"70008\"\n| summarize Rows = count(), Users = dcount(UserPrincipalName) by SourceTable\n```\n\nBaseline by app and client:\n\n```kusto\nAADNonInteractiveUserSignInLogs\n| where TimeGenerated > ago(7d)\n| where ResultType == \"70008\"\n| summarize Rows = count(), Users = dcount(UserPrincipalName) by AppDisplayName, ClientAppUsed\n| sort by Rows desc\n```\n\n70008 from an IP the user had no successful sign-in from in the previous 13 days (14 days back up to yesterday). Use it for triage, not as an alert on its own, and keep the confidential-client caveat above in mind:\n\n```kusto\nlet lookback = 14d;\nlet knownIPs =\n    union SigninLogs, AADNonInteractiveUserSignInLogs\n    | where TimeGenerated between (ago(lookback) .. ago(1d))\n    | where ResultType == \"0\"\n    | distinct UserPrincipalName, IPAddress;\nAADNonInteractiveUserSignInLogs\n| where TimeGenerated > ago(1d)\n| where ResultType == \"70008\"\n| join kind=leftanti knownIPs on UserPrincipalName, IPAddress\n| summarize Rows = count(), Apps = make_set(AppDisplayName, 5), UserAgents = make_set(UserAgent, 3)\n          by UserPrincipalName, IPAddress\n| sort by Rows desc\n```\n\nNon-interactive rows are aggregated when application, user, IP address, status and resource ID match, so `Rows` is not a request count. `ResultType` is a **string** column, so compare it with `\"70008\"`, not `70008`."
    },
    {
      "heading": "How does Conditional Access affect it?",
      "text": "- Refresh token lifetimes can't be changed with policy. Microsoft says that *\"As of January 30, 2021, refresh and session token lifetimes are no longer configurable through token lifetime policies.\"*\n- To control how often users sign in, Microsoft points to Conditional Access sign-in frequency. A refresh refused because of sign-in frequency is a different code, [70043](https://brambrinkmeier.com/entra/aadsts/70043/)."
    },
    {
      "heading": "How do I fix AADSTS70008?",
      "text": "**For the user:** sign in again when the app asks. If the app keeps failing without asking, sign out of it and back in.\n\n**For developers:** treat 70008 as *\"interactive sign-in needed\"*. Microsoft's guidance for refresh tokens is that the app *\"should handle such revocations gracefully by redirecting the user to an interactive sign-in prompt\"*. With MSAL, catch the error from the silent call and fall back to an interactive call. Don't retry the same refresh token. For single-page apps, plan for the 24-hour limit: Microsoft says they *\"must be prepared to rerun the authorization code flow using an interactive authentication\"*.\n\n**For admins:**\n1. Nothing is broken in the normal case. Exclude `\"70008\"` from failure-rate dashboards.\n2. If one app produces most of the rows and users complain, the app probably doesn't fall back to an interactive sign-in. Report it to the developer.\n3. If the third query shows rows from unfamiliar public-client IPs, check that user's other sign-ins from the same IP. If anything succeeded there, treat it as a possible token theft and [revoke the user's sessions](https://brambrinkmeier.com/guides/revoke-sessions-and-verify-entra-id/)."
    }
  ],
  "microsoftWording": {
    "text": "**ExpiredOrRevokedGrant**: The refresh token has expired due to inactivity. The token was issued on XXX and was inactive for a certain amount of time.",
    "source": "Microsoft's error code reference, as quoted on the page",
    "sourceUrl": "https://learn.microsoft.com/en-us/entra/identity-platform/reference-error-codes"
  },
  "securityReading": {
    "category": {
      "id": "token",
      "label": "Tokens and sessions"
    },
    "kind": {
      "id": "failure",
      "label": "Failure"
    },
    "attackSignal": {
      "level": "low",
      "label": "Low: usually configuration or user error"
    },
    "mitre": [],
    "tables": [
      "AADNonInteractiveUserSignInLogs",
      "SigninLogs"
    ],
    "isItAnAttack": "Usually not. A refresh token that expired or was revoked gives nobody access. The common sources are a device or app that wasn't used for a long time, and an app that keeps an old token after a password change or a session revocation.\n\n| Pattern | Likely meaning |\n|---|---|\n| 70008 from a user's known device, then an interactive success | The user came back after a long break, or after a password change, and signed in again. Benign. |\n| 70008 for many users of one app, all at once | Our interpretation: a mass revocation (for example after an incident response) or an app holding tokens far past their lifetime. Check what happened that day. |\n| 70008 shortly after you revoked a user's sessions | The revocation is working. The old token was refused. |\n| 70008 from an IP or device the user doesn't normally use | Someone presented an old refresh token from an unfamiliar place. Reasoning, not a documented detection: if the token was stolen, expiry or revocation is what stopped it. Worth a short look. |\n\n**IP caveat.** Microsoft's non-interactive sign-ins page says the IP of non-interactive sign-ins by confidential clients *\"doesn't match the actual source IP of where the refresh token request is coming from. Instead, it shows the original IP used for the original token issuance.\"* An unfamiliar IP on a 70008 row is a meaningful signal mainly for public clients (desktop, mobile and browser apps).",
    "conditionalAccess": {
      "heading": "How does Conditional Access affect it?",
      "text": "- Refresh token lifetimes can't be changed with policy. Microsoft says that *\"As of January 30, 2021, refresh and session token lifetimes are no longer configurable through token lifetime policies.\"*\n- To control how often users sign in, Microsoft points to Conditional Access sign-in frequency. A refresh refused because of sign-in frequency is a different code, [70043](https://brambrinkmeier.com/entra/aadsts/70043/)."
    }
  },
  "kql": [
    {
      "title": null,
      "leadIn": "Refresh token redemption is a non-interactive sign-in (*\"A client app uses an OAuth 2.0 refresh token to get an access token\"* is one of Microsoft's examples), so expect most rows in `AADNonInteractiveUserSignInLogs`. Microsoft doesn't document the table for this code specifically, so check:",
      "query": "union withsource=SourceTable SigninLogs, AADNonInteractiveUserSignInLogs\n| where TimeGenerated > ago(7d)\n| where ResultType == \"70008\"\n| summarize Rows = count(), Users = dcount(UserPrincipalName) by SourceTable"
    },
    {
      "title": null,
      "leadIn": "Baseline by app and client:",
      "query": "AADNonInteractiveUserSignInLogs\n| where TimeGenerated > ago(7d)\n| where ResultType == \"70008\"\n| summarize Rows = count(), Users = dcount(UserPrincipalName) by AppDisplayName, ClientAppUsed\n| sort by Rows desc"
    },
    {
      "title": null,
      "leadIn": "70008 from an IP the user had no successful sign-in from in the previous 13 days (14 days back up to yesterday). Use it for triage, not as an alert on its own, and keep the confidential-client caveat above in mind:",
      "query": "let lookback = 14d;\nlet knownIPs =\n    union SigninLogs, AADNonInteractiveUserSignInLogs\n    | where TimeGenerated between (ago(lookback) .. ago(1d))\n    | where ResultType == \"0\"\n    | distinct UserPrincipalName, IPAddress;\nAADNonInteractiveUserSignInLogs\n| where TimeGenerated > ago(1d)\n| where ResultType == \"70008\"\n| join kind=leftanti knownIPs on UserPrincipalName, IPAddress\n| summarize Rows = count(), Apps = make_set(AppDisplayName, 5), UserAgents = make_set(UserAgent, 3)\n          by UserPrincipalName, IPAddress\n| sort by Rows desc"
    }
  ],
  "kqlSectionText": "Refresh token redemption is a non-interactive sign-in (*\"A client app uses an OAuth 2.0 refresh token to get an access token\"* is one of Microsoft's examples), so expect most rows in `AADNonInteractiveUserSignInLogs`. Microsoft doesn't document the table for this code specifically, so check:\n\n```kusto\nunion withsource=SourceTable SigninLogs, AADNonInteractiveUserSignInLogs\n| where TimeGenerated > ago(7d)\n| where ResultType == \"70008\"\n| summarize Rows = count(), Users = dcount(UserPrincipalName) by SourceTable\n```\n\nBaseline by app and client:\n\n```kusto\nAADNonInteractiveUserSignInLogs\n| where TimeGenerated > ago(7d)\n| where ResultType == \"70008\"\n| summarize Rows = count(), Users = dcount(UserPrincipalName) by AppDisplayName, ClientAppUsed\n| sort by Rows desc\n```\n\n70008 from an IP the user had no successful sign-in from in the previous 13 days (14 days back up to yesterday). Use it for triage, not as an alert on its own, and keep the confidential-client caveat above in mind:\n\n```kusto\nlet lookback = 14d;\nlet knownIPs =\n    union SigninLogs, AADNonInteractiveUserSignInLogs\n    | where TimeGenerated between (ago(lookback) .. ago(1d))\n    | where ResultType == \"0\"\n    | distinct UserPrincipalName, IPAddress;\nAADNonInteractiveUserSignInLogs\n| where TimeGenerated > ago(1d)\n| where ResultType == \"70008\"\n| join kind=leftanti knownIPs on UserPrincipalName, IPAddress\n| summarize Rows = count(), Apps = make_set(AppDisplayName, 5), UserAgents = make_set(UserAgent, 3)\n          by UserPrincipalName, IPAddress\n| sort by Rows desc\n```\n\nNon-interactive rows are aggregated when application, user, IP address, status and resource ID match, so `Rows` is not a request count. `ResultType` is a **string** column, so compare it with `\"70008\"`, not `70008`.",
  "fix": {
    "short": "User: sign in again when the app asks; if it keeps failing without asking, sign out of the app and back in. Developer: treat 70008 as interactive sign-in needed and fall back from the silent call to an interactive one. Don't retry the same refresh token.",
    "steps": "**For the user:** sign in again when the app asks. If the app keeps failing without asking, sign out of it and back in.\n\n**For developers:** treat 70008 as *\"interactive sign-in needed\"*. Microsoft's guidance for refresh tokens is that the app *\"should handle such revocations gracefully by redirecting the user to an interactive sign-in prompt\"*. With MSAL, catch the error from the silent call and fall back to an interactive call. Don't retry the same refresh token. For single-page apps, plan for the 24-hour limit: Microsoft says they *\"must be prepared to rerun the authorization code flow using an interactive authentication\"*.\n\n**For admins:**\n1. Nothing is broken in the normal case. Exclude `\"70008\"` from failure-rate dashboards.\n2. If one app produces most of the rows and users complain, the app probably doesn't fall back to an interactive sign-in. Report it to the developer.\n3. If the third query shows rows from unfamiliar public-client IPs, check that user's other sign-ins from the same IP. If anything succeeded there, treat it as a possible token theft and [revoke the user's sessions](https://brambrinkmeier.com/guides/revoke-sessions-and-verify-entra-id/)."
  },
  "labReproduced": null,
  "labSectionText": null,
  "labRuns": [],
  "verification": "documentation",
  "related": [
    "700082",
    "50173",
    "70043",
    "50133",
    "700081",
    "7000110"
  ],
  "coveredBy": [],
  "sources": [
    {
      "title": "Microsoft Entra authentication and authorization error codes",
      "url": "https://learn.microsoft.com/en-us/entra/identity-platform/reference-error-codes"
    },
    {
      "title": "Refresh tokens in the Microsoft identity platform",
      "url": "https://learn.microsoft.com/en-us/entra/identity-platform/refresh-tokens"
    },
    {
      "title": "Configurable token lifetimes in the Microsoft identity platform",
      "url": "https://learn.microsoft.com/en-us/entra/identity-platform/configurable-token-lifetimes"
    },
    {
      "title": "Non-interactive user sign-ins",
      "url": "https://learn.microsoft.com/en-us/entra/identity/monitoring-health/concept-noninteractive-sign-ins"
    },
    {
      "title": "AADNonInteractiveUserSignInLogs table reference (Azure Monitor)",
      "url": "https://learn.microsoft.com/en-us/azure/azure-monitor/reference/tables/aadnoninteractiveusersigninlogs"
    },
    {
      "title": "SigninLogs table reference (Azure Monitor)",
      "url": "https://learn.microsoft.com/en-us/azure/azure-monitor/reference/tables/signinlogs"
    }
  ],
  "published": "2026-10-30",
  "lastUpdated": "2026-10-30",
  "lastVerified": null,
  "license": "CC BY 4.0",
  "licenseUrl": "https://creativecommons.org/licenses/by/4.0/",
  "licenseNote": "CC BY 4.0 covers the author's own text and classification fields. Quoted Microsoft error wording belongs to Microsoft, is quoted with its source and can change.",
  "attribution": "Bram Brinkmeier, \"AADSTS70008: The refresh token has expired or was revoked (fix)\", https://brambrinkmeier.com/entra/aadsts/70008/ (CC BY 4.0)",
  "microsoftErrorLookup": "https://login.microsoftonline.com/error?code=70008"
}
