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

  1. Close all leftover Google consent tabs from earlier attempts and re-run auth login from scratch
  2. Run only one OAuth login at a time on the machine so port 1456 is owned by a single flow
  3. 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

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


AI-assisted analysis of zeroclaw-labs/zeroclaw@88bb9c8533 (2026-08-23). Data as JSON: /api/errors/e3807a6af8119411. Report an issue: GitHub.