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

  1. Read the error code inside the response text: invalid_grant means restart the flow with a fresh code
  2. invalid_client means fix the client_secret / env values sent in bodyParams
  3. Verify the redirect_uri registered on the server matches params.redirectUri byte for byte
  4. Guard against double exchange — mark the code consumed before the POST
  5. 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

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


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)