ruvnet/ruflo · error · Error

Token exchange failed: ${response.status}

Error message

Token exchange failed: ${response.status}

What it means

The token-exchange POST to config.tokenEndpoint returned a non-2xx status; the thrown message carries only the HTTP code while the response body is logged server-side. Statuses map to standard OAuth failures: 400 invalid/expired code or redirect_uri mismatch, 401 client authentication failure, 5xx provider trouble.

Source

Thrown at v3/@claude-flow/mcp/src/oauth.ts:199

      params.set('client_secret', this.config.clientSecret);
    }

    if (pending.codeVerifier) {
      params.set('code_verifier', pending.codeVerifier);
    }

    const response = await fetch(this.config.tokenEndpoint, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded',
      },
      body: params.toString(),
    });

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

    const data = (await response.json()) as TokenResponse;
    const tokens = this.parseTokenResponse(data);

    await this.tokenStorage.save('default', tokens);
    this.logger.info('Token exchange successful');
    this.emit('tokens:received', { expiresIn: tokens.expiresIn });

    return tokens;
  }

  /**
   * Refresh access token using refresh token
   */
  async refreshTokens(storageKey: string = 'default'): Promise<OAuthTokens> {
    const existing = await this.tokenStorage.load(storageKey);
    if (!existing?.refreshToken) {

View on GitHub (pinned to fa13ee4ad6)

Solutions

  1. Read the logged response body — providers return error codes (invalid_grant vs invalid_client) that pinpoint the cause
  2. Never retry a used code: restart the flow with a fresh createAuthorizationRequest
  3. Verify clientId, clientSecret, and redirectUri match the app registration byte-for-byte (scheme, host, path, no trailing slash)
  4. If the provider requires PKCE, ensure the codeVerifier returned by createAuthorizationRequest is included in the exchange

Example fix

// before
await oauth.exchangeCode(code, state); // 400: code already redeemed by an earlier retry

// after
try {
  const tokens = await oauth.exchangeCode(code, state);
} catch (e) {
  if (e instanceof Error && e.message.includes('Token exchange failed: 400')) {
    const authReq = await oauth.createAuthorizationRequest(scopes);
    return res.redirect(authReq.url); // fresh flow instead of replaying the code
  }
  throw e;
}
Defensive patterns

Strategy: try-catch

Try / catch

try {
  const tokens = await oauth.exchangeCode(code, state);
} catch (e) {
  const m = /Token exchange failed: (\d+)/.exec(String((e as Error).message));
  if (m) {
    const status = Number(m[1]);
    if (status === 400 || status === 401) return restartAuthorizationFlow(); // config/grant problem — no retry
    if (status >= 500) return retryWithBackoff(); // provider-side, transient
  }
  throw e;
}

Prevention

When it happens

Trigger: exchangeCode with an authorization code that expired or was already redeemed, a redirect_uri differing from the authorization request, wrong clientId/clientSecret (or wrong client-auth method), or a provider outage returning 5xx.

Common situations: Retrying or replaying an exchange after the code was already used (codes are one-shot); redirect URI registered in the OAuth app differing in scheme/trailing slash; a rotated secret not updated; PKCE required but the codeVerifier not sent.

Related errors


AI-assisted analysis of ruvnet/ruflo@fa13ee4ad6 (2026-08-18). Data as JSON: /api/errors/915828567332484a. Report an issue: GitHub.