decolua/9router · error · Error

Token exchange failed: ${error}

Error message

Token exchange failed: ${error}

What it means

Thrown by exchangeClaudeCode when Anthropic's OAuth token endpoint (CLAUDE_CONFIG.tokenUrl) returns a non-2xx status to the authorization_code grant request. The raw response body (Anthropic's OAuth error JSON, e.g. invalid_grant) is appended to the message. The request is sent as JSON (not form-urlencoded) with a PKCE code_verifier and optional `#`-delimited state parsed out of the code.

Source

Thrown at src/lib/oauth/services/claude.js:67

      state: codeState || state,
      grant_type: "authorization_code",
      client_id: CLAUDE_CONFIG.clientId,
      redirect_uri: redirectUri,
      code_verifier: codeVerifier,
    };

    const response = await fetch(CLAUDE_CONFIG.tokenUrl, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Accept: "application/json",
      },
      body: JSON.stringify(tokenPayload),
    });

    if (!response.ok) {
      const error = await response.text();
      throw new Error(`Token exchange failed: ${error}`);
    }

    return await response.json();
  }

  /**
   * Save Claude tokens to server
   */
  async saveTokens(tokens) {
    const { server, token, userId } = getServerCredentials();

    // Server will auto-generate displayName based on existing account count
    const response = await fetch(`${server}/api/cli/providers/claude`, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${token}`,
        "X-User-Id": userId,

View on GitHub (pinned to 90b52e06ff)

Solutions

  1. Read the appended response body — it names Anthropic's exact OAuth error (usually invalid_grant)
  2. Re-run the full connect flow to get a fresh authorization code; codes are single-use and short-lived
  3. Ensure redirect_uri passed to exchangeClaudeCode is byte-identical to the one used in buildClaudeAuthUrl
  4. Verify the code_verifier belongs to the same session/state as the code (a restarted CLI generates a new verifier)

Example fix

// before: raw body only
if (!response.ok) {
  const error = await response.text();
  throw new Error(`Token exchange failed: ${error}`);
}
// after: surface status code too for easier diagnosis
if (!response.ok) {
  const error = await response.text();
  throw new Error(`Token exchange failed (HTTP ${response.status}): ${error}`);
}
Defensive patterns

Strategy: try-catch

Validate before calling

if (!code) throw new Error('No authorization code to exchange');
if (!codeVerifier || codeVerifier.length < 43) throw new Error('Invalid PKCE code_verifier — restart the full connect flow');
if (redirectUri !== originalAuthorizeRedirectUri) throw new Error('redirect_uri mismatch between authorize and token steps');

Type guard

function isExchangable(ctx) { return ctx && typeof ctx.code === 'string' && ctx.code.length > 0 && typeof ctx.codeVerifier === 'string' && typeof ctx.redirectUri === 'string'; }

Try / catch

try {
  const tokens = await service.exchangeClaudeCode(code, redirectUri, codeVerifier, state);
} catch (e) {
  if (e.message.startsWith('Token exchange failed')) {
    if (e.message.includes('invalid_grant')) {
      console.error('Code used/expired — restart connect for a fresh code.');
    } else throw e;
  } else throw e;
}

Prevention

When it happens

Trigger: Authorization code already used or expired (invalid_grant); code_verifier doesn't match the PKCE challenge; redirect_uri differs from the one used in the authorize step; state mismatch; malformed code string from a mangled callback.

Common situations: Re-running connect with a stale code from a previous attempt (codes are single-use); user taking too long between authorize and exchange; local callback port changed between authorize and token calls; Claude OAuth endpoint outage or client_id rotation.

Related errors


AI-assisted analysis of decolua/9router@90b52e06ff (2026-08-30). Data as JSON: /api/errors/0e646667706019cb. Report an issue: GitHub.