Kuberwastaken/claurst · error
OAuth state mismatch — possible CSRF attack
Error message
OAuth state mismatch — possible CSRF attack
What it means
run_callback_server validates the state query parameter on the OAuth browser redirect against the state generated when the authorization URL was built. A mismatch means the redirect did not originate from the authorization request this client started — the classic CSRF defense mandated by the OAuth 2.0 spec — so the authorization code is discarded and login aborts.
Solutions
- Close all stale login tabs and previous CLI sessions, then retry the login flow from scratch.
- Ensure no other instance of the tool is running and holding/reusing the same callback port.
- Retry the login; each attempt generates a fresh state, so a one-off stale-redirect collision is resolved by a new attempt.
- If a proxy or browser extension rewrites query parameters, whitelist the localhost callback URL or bypass the proxy for localhost.
- Do not bypass this check — it protects against CSRF; only investigate the environment (proxies, extensions) if it reproduces consistently.
Example fix
// before: treating the mismatch as retryable inside the server loop can accept a forged code
if received_state.as_deref() != Some(expected_state) {
bail!("OAuth state mismatch — possible CSRF attack");
}
// after: keep the bail, but include both states to make diagnosis possible
if received_state.as_deref() != Some(expected_state) {
bail!(
"OAuth state mismatch — possible CSRF attack (expected {}..., got {}...)",
&expected_state[..8.min(expected_state.len())],
received_state.as_deref().map(|s| &s[..8.min(s.len())]).unwrap_or("<none>")
);
} Defensive patterns
Strategy: validation
Validate before calling
// Before starting the flow, ensure a single clean attempt
assert!(expected_state.len() >= 32, "state must be cryptographically random");
// Ensure no other login session is active on the callback port
if std::net::TcpListener::bind(("127.0.0.1", callback_port)).is_err() {
eprintln!("callback port in use — close other login sessions first");
} Type guard
fn state_matches(received: Option<&str>, expected: &str) -> bool {
// constant-time-ish exact compare; both must be present and equal
match received {
Some(s) => s.len() == expected.len()
&& s.bytes().zip(expected.bytes()).fold(0u8, |acc, (a, b)| acc | (a ^ b)) == 0,
None => false,
}
} Try / catch
match wait_for_auth_code_impl(&listener, &state).await {
Ok(code) => /* exchange */,
Err(e) if e.to_string().contains("state mismatch") => {
eprintln!("Callback state mismatch: close stale login tabs and retry the login from scratch.");
}
Err(e) => return Err(e),
} Prevention
- Close previous login tabs before starting a new OAuth flow
- Never run two login flows concurrently on the same machine/port
- Do not edit or truncate the authorization URL copied into a browser
- Never disable or weaken the state check — it is the CSRF defense
- Bypass proxies/extensions for localhost callback URLs if they rewrite queries
When it happens
Trigger: The browser hits the local /callback endpoint with a state parameter that is missing or differs from expected_state: the user completed an older login tab whose state differs, a different app instance is listening on the same port, the URL was truncated or edited, the state cookie/query was mangled by a proxy or browser extension, or an actual cross-site request forges the callback.
Common situations: Developers hit this when running two login flows in parallel (both bind adjacent or reused ports), when a stale browser tab from a previous attempt fires its redirect into the current server, when copying the auth URL partially, or behind corporate proxies that rewrite query strings.
Related errors
- OAuth callback path mismatch: expected
- OAuth state mismatch — possible CSRF attack
- Missing code or state in OAuth callback
- No query string in callback
- Callback server dropped
AI-assisted analysis of Kuberwastaken/claurst@b0637c97ec (2026-09-10).
Data as JSON: /api/errors/a5ff210eb53dbccf.
Report an issue: GitHub.
Appendix: source
Thrown at src-rust/crates/cli/src/oauth_flow.rs:350
let received_state = parsed_url
.query_pairs()
.find(|(k, _)| k == "state")
.map(|(_, v)| v.to_string());
// Send success redirect to the browser before validating. The same success
// page is shown on both the valid and error paths (browser UX); request
// validation happens after this redirect is written.
let location = oauth::CLAUDEAI_SUCCESS_URL;
let response = format!(
"HTTP/1.1 302 Found\r\nLocation: {}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
location
);
writer.write_all(response.as_bytes()).await?;
// Validate
if received_state.as_deref() != Some(expected_state) {
bail!("OAuth state mismatch — possible CSRF attack");
}
let code = code.context("No authorization code in callback")?;
Ok(code)
}
/// Read a single line from stdin (for manual code paste).
async fn read_line_from_stdin() -> anyhow::Result<String> {
print!(" Or paste authorization code here: ");
use std::io::Write;
std::io::stdout().flush().ok();
let mut line = String::new();
let stdin = tokio::io::stdin();
let mut reader = BufReader::new(stdin);
reader.read_line(&mut line).await?;
Ok(line)
}
View on GitHub (pinned to b0637c97ec)