moeru-ai/airi · error · Error
Token exchange failed
Error message
Token exchange failed: ${response.status} ${error} What it means
The token endpoint POST (authorization_code grant) returned a non-2xx status; the message embeds the HTTP status and the raw response text, which per RFC 6749 S5.2 contains an error code such as invalid_grant, invalid_client, or invalid_request. This is the exchange step of the code flow, reached only after the state check passed.
Solutions
- Read the error code inside the response text: invalid_grant means restart the flow with a fresh code
- invalid_client means fix the client_secret / env values sent in bodyParams
- Verify the redirect_uri registered on the server matches params.redirectUri byte for byte
- Guard against double exchange — mark the code consumed before the POST
- Ensure the code_verifier is the same one used to build the authorize challenge
Example fix
// before
window.addEventListener('load', () => exchange(code))
// a second handler invocation exchanges the same code again → invalid_grant
// after
let exchanged = false
window.addEventListener('load', () => {
if (exchanged) return
exchanged = true
exchange(code)
}) Defensive patterns
Strategy: try-catch
Try / catch
try {
const tokens = await exchangeCodeForTokens(code, flowState, params, urlState)
}
catch (err) {
const [, , body] = err.message.match(/Token exchange failed: (\d+) (.*)/) ?? []
const oauthError = body ? JSON.parse(body).error : undefined
if (oauthError === 'invalid_grant') restartLogin()
else if (oauthError === 'invalid_client') throw new Error('Check client secret configuration')
} Prevention
- Exchange the code exactly once, immediately after the redirect
- Keep code_verifier stored alongside state
- Register redirect URIs exactly — scheme, host, path, no trailing slash
- Never log or reuse authorization codes
When it happens
Trigger: Authorization code already redeemed or expired (about 60 s lifetime) — invalid_grant; redirect_uri differing from the registered one; wrong or missing clientSecret for a confidential client; PKCE code_verifier not matching the challenge sent at authorize time; wrong client_id.
Common situations: A callback handler firing twice and exchanging the same code (React StrictMode double effects); dev server restart between authorize and callback; env typo in the client secret; a proxy stripping the POST body; registered redirect URI with a trailing-slash mismatch.
Related errors
- OIDC flow status has expired or is no longer valid.
- OIDC state mismatch — possible CSRF attack
- Failed to add provider
- Failed to bookmark character
- Failed to fetch providers
AI-assisted analysis of moeru-ai/airi@677329427f (2026-08-18).
Data as JSON: /api/errors/2b9f642d0f8cca3f.
Report an issue: GitHub.
Appendix: source
Thrown at packages/stage-ui/src/libs/auth-oidc.ts:106
code_verifier: flowState.codeVerifier,
resource: SERVER_URL,
}
// Confidential clients must send the secret during token exchange.
if (params.clientSecret)
bodyParams.client_secret = params.clientSecret
const body = new URLSearchParams(bodyParams)
const response = await fetch(new URL(OIDC_TOKEN_PATH, SERVER_URL), {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
})
if (!response.ok) {
const error = await response.text()
throw new Error(`Token exchange failed: ${response.status} ${error}`)
}
return await response.json()
}
/**
* Refresh an access token using a refresh token (RFC 6749 S6).
* Pure function — returns new tokens without writing to any store.
*/
export async function refreshAccessToken(
clientId: string,
refreshToken: string,
clientSecret?: string,
): Promise<TokenResponse> {
const params: Record<string, string> = {
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: clientId,View on GitHub (pinned to 677329427f)