Skip to content

AADSTS54005: Authorization code already used (fix)

Short answer: AADSTS54005 means an app tried to redeem an OAuth 2.0 authorization code that had already been redeemed. Usually the app sent the same code twice: a double callback, a retry, or a page reload. It is mostly an app bug with low attack value. The app should get a new code, or use the refresh token it already has.

Fix: Developer: redeem each authorization code once, make the callback handler idempotent, don't retry a redemption after a timeout, and use the refresh token after the first redemption. User: start the sign-in again from the app's start page instead of reloading. Full steps

At a glance
CodeAADSTS54005
CategoryTokens and sessions
TypeFailure
Attack signalLow: usually configuration or user error
Where to lookSigninLogs, AADNonInteractiveUserSignInLogs
Microsoft lookuplogin.microsoftonline.com/error?code=54005

Microsoft’s description:

OAuth2 Authorization code was already redeemed, please retry with a new valid code or use an existing refresh token.

In the authorization code flow the app gets a code at its redirect URI and then sends it to the /token endpoint to get tokens. 54005 means a code reached the token endpoint a second time after it had already been exchanged.

Microsoft’s authorization code flow page says “Authorization codes are short lived” and lists the generic token-endpoint error for a bad code: invalid_grant, “The authorization code or PKCE code verifier is invalid or has expired.” Our reading: the app sees invalid_grant, and AADSTS54005 is in the error description and error_codes. Microsoft’s error reference tells developers to branch on the error field and not on the AADSTS number.

Usually not. The common causes are in the app:

Pattern Likely meaning
A success and a 54005 for the same user and app, seconds apart, same IP The app redeemed the code twice: two callback handlers, a retry after a slow response, or a framework that runs the callback twice. The first call worked. Benign.
54005 after the user reloads or uses the back button on the callback page The page sends the old code again. Benign.
54005 for many users of one web app, from your own server’s IP The app’s callback code has a bug. Fix the app.
54005 from a different IP than the success, for the same user and app Our interpretation, not documented: someone else got hold of the code and tried it after the real app. The refusal is the protection working, but find out how the code leaked (logs, referrer headers, a shared proxy).

Going by Microsoft’s description, the second redemption gets nothing: a code that was already used can’t be exchanged for tokens again.

Microsoft doesn’t document which sign-in table 54005 is logged in. Check where it lands in your workspace:

union withsource=SourceTable SigninLogs, AADNonInteractiveUserSignInLogs
| where TimeGenerated > ago(7d)
| where ResultType == "54005"
| summarize Rows = count(), Users = dcount(UserPrincipalName) by SourceTable

Which apps produce it, and from how many IPs:

union SigninLogs, AADNonInteractiveUserSignInLogs
| where TimeGenerated > ago(7d)
| where ResultType == "54005"
| summarize Rows = count(),
Users = dcount(UserPrincipalName),
IPs = dcount(IPAddress),
SampleIPs = make_set(IPAddress, 5)
by AppDisplayName, AppId
| sort by Rows desc

54005 from an IP that is different from the one that signed in successfully to the same app shortly before. This is the pattern from the last table row above, which Microsoft doesn’t document as an attack signal:

let window = 10m;
let reuse =
union SigninLogs, AADNonInteractiveUserSignInLogs
| where TimeGenerated > ago(1d)
| where ResultType == "54005"
| project ReuseTime = TimeGenerated, UserPrincipalName, AppId, AppDisplayName, ReuseIP = IPAddress;
let success =
union SigninLogs, AADNonInteractiveUserSignInLogs
| where TimeGenerated > ago(1d)
| where ResultType == "0"
| project SuccessTime = TimeGenerated, UserPrincipalName, AppId, SuccessIP = IPAddress;
reuse
| join kind=inner success on UserPrincipalName, AppId
| where SuccessTime between ((ReuseTime - window) .. ReuseTime)
| where ReuseIP != SuccessIP
| summarize Reuses = count(), ReuseIPs = make_set(ReuseIP, 5), SuccessIPs = make_set(SuccessIP, 5)
by UserPrincipalName, AppDisplayName
| sort by Reuses desc

The 10-minute window is a starting point. Expect false positives: a web app redeems the code from its server, so its IP can differ from the browser IP in the user’s interactive sign-in. Compare against the app’s known server IPs before you treat a hit as suspicious. Non-interactive rows are aggregated when application, user, IP address, status and resource ID match.

For the user: start the sign-in again from the app’s start page, not by reloading the page you landed on. If it keeps happening, report it to whoever runs the app.

For developers:

  1. Redeem each code once. Make the callback handler idempotent: if a second request arrives with the same code, don’t send it to /token again.
  2. Don’t retry a code redemption automatically after a timeout. Start a new sign-in instead.
  3. After the first redemption, use the refresh token, as Microsoft’s description says. The auth code flow page: “Provide the refresh_token instead of the code.” Also: “Refresh tokens aren’t revoked when used to acquire new access tokens. You’re expected to discard the old refresh token.”
  4. Use MSAL instead of hand-written token code; it handles the redemption and cache for you.

For admins:

  1. Use the second query to find the app and hand the time window to its developer.
  2. If the third query shows reuse from IPs that aren’t the app’s servers, treat it as a possible leak of authorization codes and review where codes could be logged or forwarded.

Sources

  1. Microsoft Entra authentication and authorization error codes
  2. Microsoft identity platform and OAuth 2.0 authorization code flow
  3. Non-interactive user sign-ins
  4. SigninLogs table reference (Azure Monitor)