zeroclaw-labs/zeroclaw · error · anyhow::Error
xAI OAuth state mismatch
Error message
xAI OAuth state mismatch
What it means
CSRF guard of the xAI flow: `parse_code_from_redirect` compared the callback's `state` to the `expected_state` and they differ. The xAI parser hard-requires state whenever an expectation is passed — a missing state is its own separate error — so this specifically means a present-but-different value. Used by `receive_loopback_code` (loopback 127.0.0.1:56121) and direct callers.
Source
Thrown at crates/zeroclaw-providers/src/auth/xai_oauth.rs:417
let trimmed = input.trim();
if trimmed.is_empty() {
anyhow::bail!("No xAI OAuth code provided");
}
let query = trimmed.split_once('?').map_or(trimmed, |(_, query)| query);
let params = parse_query_params(query);
if let Some(err) = params.get("error") {
let desc = params
.get("error_description")
.cloned()
.unwrap_or_else(|| "xAI OAuth authorization failed".to_string());
anyhow::bail!("{err}: {desc}");
}
if let Some(expected) = expected_state {
let actual = params
.get("state")
.ok_or_else(|| anyhow::Error::msg("xAI OAuth callback missing state parameter"))?;
if actual != expected {
anyhow::bail!("xAI OAuth state mismatch");
}
}
if let Some(code) = params.get("code")
&& !code.trim().is_empty()
{
return Ok(code.trim().to_string());
}
if expected_state.is_none() && !trimmed.contains('=') && !trimmed.contains('?') {
return Ok(trimmed.to_string());
}
anyhow::bail!("xAI OAuth callback missing code parameter")
}
pub fn extract_account_id_from_jwt(token: &str) -> Option<String> {
let payload = decode_jwt_payload(token)?;
payload
.get("email")
.or_else(|| payload.get("sub"))View on GitHub (pinned to 88bb9c8533)
Solutions
- Restart the flow so one PkceState builds the authorize URL and validates the callback
- Close old tabs on auth.x.ai that will redirect with the previous state
- When restoring across process restarts, persist and reload the exact state used in the authorize URL
- Serialize logins so only one flow owns the loopback port at a time
Example fix
// before: restored state does not match what was sent let pkce = restore_pkce_state(code_verifier, "guessed-state".into()); // callback state=actual -> "xAI OAuth state mismatch" // after: persist the state you embedded and restore it verbatim let pkce = restore_pkce_state(saved_code_verifier, saved_state);
Defensive patterns
Strategy: validation
Validate before calling
fn xai_state_matches(input: &str, expected: &str) -> bool {
input.split_once('?').map_or(false, |(_, q)| {
q.split('&').any(|pair| pair == format!("state={}", expected))
})
}
if xai_state_matches(path, &pkce.state) {
let code = parse_code_from_redirect(path, Some(&pkce.state))?;
} Try / catch
match parse_code_from_redirect(path, Some(&pkce.state)) {
Ok(code) => exchange(code),
Err(e) if e.to_string().contains("xAI OAuth state mismatch") => restart_login_flow().await,
Err(e) => return Err(e),
} Prevention
- Persist and restore the exact state used in the authorize URL (restore_pkce_state)
- Use one PkceState per attempt, end to end
- Close stale auth.x.ai tabs before restarting login on port 56121
When it happens
Trigger: `parse_code_from_redirect("/callback?code=abc&state=bad", Some("xyz"))`; via the loopback listener, a stale browser redirect from an earlier login attempt lands after a new flow started on the same fixed port.
Common situations: Concurrent or restarted logins sharing port 56121; `restore_pkce_state` called with a guessed or wrong saved `state` value across a process restart; stale auth.x.ai tabs.
Related errors
- OAuth state mismatch
- OAuth state mismatch
- OAuth state mismatch: expected {expected}, got {actual}
- xAI auth profile is not OAuth-based: {profile_id}
- xAI auth profile is missing token set: {profile_id}
AI-assisted analysis of zeroclaw-labs/zeroclaw@88bb9c8533 (2026-08-23).
Data as JSON: /api/errors/96db7567005631ca.
Report an issue: GitHub.