Hmbown/CodeWhale · error

OAuth callback state did not match the pending login

Error message

OAuth callback state did not match the pending login

What it means

The browser-based OAuth flow binds a random `state` value to the pending login and requires the callback to echo it back; `accept_callback` compares them. A mismatch means the callback the library received belongs to a different (or forged) login attempt, so it refuses to accept the authorization code — this is the standard CSRF protection for the OAuth redirect flow.

Solutions

  1. Restart the sign-in flow from scratch and use only the browser tab/window it opens
  2. Discard stale callback URLs from previous attempts
  3. Check for another login in progress that replaced the pending state, and cancel it before retrying

Example fix

// before: callback URL reused from an earlier login (state stale)
accept_callback(current_state, parse_callback("http://localhost/callback?code=x&state=OLD"))?
// after: use the fresh callback from the current login
accept_callback(current_state, parse_callback(fresh_callback_url))?
Defensive patterns

Strategy: try-catch

Validate before calling

fn callback_matches_pending(expected: &str, url: &Url) -> bool {
    url.query_pairs().any(|(k, v)| k == "state" && v == expected)
}

Try / catch

match accept_callback(expected_state, outcome) {
    Ok(code) => code,
    Err(e) if e.to_string().contains("state did not match") => {
        // stale or foreign callback: restart the login flow cleanly
        cancel_pending_login();
        start_new_login()?  // fresh state, fresh browser tab
    }
    Err(e) => return Err(e),
}

Prevention

When it happens

Trigger: `accept_callback(expected_state, CallbackOutcome::Success { code, state })` receives a `state` that does not equal the `expected_state` recorded when the login started — e.g. two logins interleaved, a stale browser tab replaying an old callback, or a state parameter stripped/rewritten by a redirect.

Common situations: Starting a second sign-in before finishing the first, so the old tab's callback no longer matches the new pending state; a proxy or URL rewriter mangling the query string; pasting a callback URL from an earlier attempt.

Related errors


AI-assisted analysis of Hmbown/CodeWhale@73e0f67d83 (2026-09-22). Data as JSON: /api/errors/58411effba0a8550. Report an issue: GitHub.

Appendix: source

Thrown at crates/tui/src/oauth.rs:1221

        return Ok(CallbackOutcome::Error {
            error,
            description,
            state,
        });
    }
    let code = code
        .filter(|c| !c.trim().is_empty())
        .context("OAuth callback missing authorization code")?;
    let state = state
        .filter(|s| !s.trim().is_empty())
        .context("OAuth callback missing state")?;
    Ok(CallbackOutcome::Success { code, state })
}

pub fn accept_callback(expected_state: &str, outcome: CallbackOutcome) -> Result<String> {
    match outcome {
        CallbackOutcome::Success { code, state } => {
            anyhow::ensure!(
                state == expected_state,
                "OAuth callback state did not match the pending login"
            );
            Ok(code)
        }
        CallbackOutcome::Error {
            error,
            description,
            state,
        } => {
            if let Some(state) = state {
                anyhow::ensure!(
                    state == expected_state,
                    "OAuth error callback state did not match the pending login"
                );
            }
            let detail = description
                .filter(|text| !text.trim().is_empty())

View on GitHub (pinned to 73e0f67d83)