zeroclaw-labs/zeroclaw · error
OAuth state mismatch
Error message
OAuth state mismatch
What it means
The loopback callback server (127.0.0.1:1456) accepted a browser request to /auth/callback whose state query parameter differs from the PKCE state generated for the current pending login. This is the CSRF protection of the OAuth flow: a matching state proves the callback belongs to this login attempt. The server answers the browser with HTTP 400 and a 'State mismatch' page before bailing.
Source
Thrown at crates/zeroclaw-providers/src/auth/gemini_oauth.rs:372
accept_result = async {
tokio::time::timeout(timeout, listener.accept()).await
} => {
match accept_result {
Ok(Ok((mut stream, _))) => {
let mut buffer = vec![0u8; 4096];
let n = stream
.read(&mut buffer)
.await
.context("Failed to read from callback connection")?;
let request = String::from_utf8_lossy(&buffer[..n]);
let (code, state) = parse_callback_request(&request)?;
if state != expected_state {
let response = "HTTP/1.1 400 Bad Request\r\nContent-Type: text/html\r\n\r\n\
<html><body><h1>State mismatch</h1><p>Please try again.</p></body></html>";
let _ = stream.write_all(response.as_bytes()).await;
anyhow::bail!("OAuth state mismatch");
}
let response = "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n\
<html><body><h1>Success!</h1><p>You can close this window and return to the terminal.</p></body></html>";
let _ = stream.write_all(response.as_bytes()).await;
Ok(code)
}
Ok(Err(e)) => {
::zeroclaw_log::record!(
ERROR,
::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
.with_outcome(::zeroclaw_log::EventOutcome::Failure)
.with_attrs(::serde_json::json!({
"oauth_provider": "gemini",
"phase": "callback_accept",
"error": format!("{}", e),
})),View on GitHub (pinned to 88bb9c8533)
Solutions
- Close all leftover Google consent tabs from earlier attempts and re-run auth login from scratch
- Run only one OAuth login at a time on the machine so port 1456 is owned by a single flow
- If the browser flow keeps failing, use the printed manual path: copy the final callback URL and paste it at the stdin prompt, or use auth paste-redirect
Defensive patterns
Strategy: retry
Validate before calling
// Ensure only one flow owns the callback port before listening.
match tokio::net::TcpListener::bind("127.0.0.1:1456").await {
Ok(l) => { /* safe to run the loopback flow */ let _ = l; }
Err(_) => { /* another flow owns 1456; use paste-redirect instead */ }
} Try / catch
match receive_loopback_code(&pkce.state, timeout).await {
Ok(code) => code,
Err(e) if e.to_string().contains("state mismatch") => {
// stale tab or concurrent login hit the callback; restart cleanly
eprintln!("close old consent tabs, then retry");
receive_loopback_code(&pkce.state, timeout).await?
}
Err(e) => return Err(e),
} Prevention
- Close consent tabs from earlier login attempts before re-running auth login
- Run one OAuth login at a time per machine (port 1456 is shared)
- Fall back to auth paste-redirect when the loopback path keeps mismatching
When it happens
Trigger: receive_loopback_code is waiting for the redirect and receives a callback whose state is from a different, earlier authorize URL — e.g. the user finally loads a stale browser tab bookmarked from a previous auth login run, or two zeroclaw login processes share port 1456 and one catches the other's redirect.
Common situations: Re-running auth login while an old consent tab is still open; two terminals running gemini/openai logins concurrently on the same machine (both bind localhost:1456); browser extension or privacy tool reissuing the navigation with a mangled query string.
Related errors
- OAuth state mismatch: expected {expected}, got {actual}
- Google device code request failed ({}): {}
- Device code expired before authorization was completed
- User denied authorization
- Device code expired
AI-assisted analysis of zeroclaw-labs/zeroclaw@88bb9c8533 (2026-08-23).
Data as JSON: /api/errors/e3807a6af8119411.
Report an issue: GitHub.