Kuberwastaken/claurst · error · anyhow::Error
Token exchange failed
Error message
Token exchange failed ({}): {} What it means
After receiving the OAuth authorization code, exchange_code_for_tokens POSTs it (with client_id, PKCE code_verifier, grant_type, and redirect_uri) to the Codex token endpoint. This error is raised when the token endpoint returns a non-2xx HTTP status; the message embeds the status code and the response body so the developer can see the provider's rejection reason (e.g. invalid_grant, invalid_client).
Solutions
- Read the embedded status and body in the message: `invalid_grant` means the code is expired or already used — restart the OAuth flow to get a fresh code
- Retry the full login flow (`claurst auth login`) immediately and complete the browser step without long delays
- Verify network access to the token endpoint (no proxy/VPN interference); test with `curl -X POST <CODEX_TOKEN_URL>`
- If it persists, check for a claurst/version mismatch with the provider's OAuth client (client_id/redirect_uri changes) and update claurst
- Check system clock accuracy (NTP) — skew can break token validation server-side
Example fix
// before: re-running the exchange with a stale, already-consumed code
exchange_code_for_tokens("old_used_code", &verifier).await
// -> Token exchange failed (400 Bad Request): {"error":"invalid_grant"...}
// after: start a fresh flow so a new code+verifier pair is issued
run_oauth_flow_with_label("Codex").await?; Defensive patterns
Strategy: retry
Validate before calling
// Pre-flight: confirm the token endpoint is reachable before the flow
let ok = reqwest::get(CODEX_TOKEN_URL.replace("/token", "/.well-known/openid-configuration"))
.await.map(|r| r.status().is_success()).unwrap_or(false);
anymore::ensure!(ok, "token endpoint unreachable — check network/proxy"); Try / catch
match run_oauth_flow_with_label("Codex").await {
Err(e) if e.to_string().contains("Token exchange failed") => {
// invalid_grant: code expired/used — restart the whole flow once
run_oauth_flow_with_label("Codex").await?;
}
other => other?,
} Prevention
- Exchange the code immediately after the callback — codes are single-use and short-lived
- Never re-run the exchange with a previously used authorization code
- Keep system clock synchronized (NTP)
- Exclude the auth host from corporate proxies/TLS inspection
When it happens
Trigger: reqwest receives a successful transport response but `resp.status().is_success()` is false for the POST to CODEX_TOKEN_URL. Typical provider responses: 400 invalid_grant (code already used or expired), 400 invalid_grant (PKCE code_verifier mismatch), 401 invalid_client (wrong CODEX_CLIENT_ID), or 5xx from the auth service.
Common situations: Replaying an authorization code that was already exchanged (codes are single-use); the callback took longer than the code's ~few-minute lifetime; a proxy/corporate network intercepts the POST; clock skew invalidating PKCE/token validation; the provider rotated client credentials between the auth request and the exchange.
Related errors
- Token exchange failed
- exchange_code: HTTP
- Bridge register: server returned
- API key creation failed
- refresh: HTTP
AI-assisted analysis of Kuberwastaken/claurst@b0637c97ec (2026-09-10).
Data as JSON: /api/errors/8704a8743dffe64c.
Report an issue: GitHub.
Appendix: source
Thrown at src-rust/crates/cli/src/codex_oauth_flow.rs:205
let params = [
("client_id", CODEX_CLIENT_ID),
("code", code),
("code_verifier", verifier),
("grant_type", "authorization_code"),
("redirect_uri", CODEX_REDIRECT_URI),
];
let resp = client
.post(CODEX_TOKEN_URL)
.form(¶ms)
.send()
.await
.map_err(|e| anyhow!("Failed to exchange code: {}", e))?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
bail!("Token exchange failed ({}): {}", status, body);
}
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| anyhow!("Failed to parse token response: {}", e))?;
let access_token = body["access_token"]
.as_str()
.unwrap_or("")
.to_string();
if access_token.is_empty() {
bail!("No access_token in response");
}
let refresh_token = body["refresh_token"].as_str().map(|s| s.to_string());
let account_id = extract_account_id_from_jwt(&access_token);
View on GitHub (pinned to b0637c97ec)