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

  1. Close all stale login tabs and previous CLI sessions, then retry the login flow from scratch.
  2. Ensure no other instance of the tool is running and holding/reusing the same callback port.
  3. Retry the login; each attempt generates a fresh state, so a one-off stale-redirect collision is resolved by a new attempt.
  4. If a proxy or browser extension rewrites query parameters, whitelist the localhost callback URL or bypass the proxy for localhost.
  5. 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

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


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)