zed-industries/zed · error

OAuth state mismatch

Error message

OAuth state mismatch

What it means

Raised in do_oauth_flow when the `state` query parameter of the received OAuth callback does not equal the random state value generated for the PKCE authorize request. OAuth state is a CSRF protection binding the browser redirect to this specific flow; a mismatch means the redirect either belongs to a different/concurrent flow, was replayed, or was forged. The library refuses to exchange the authorization code in that case.

Solutions

  1. Restart sign_in cleanly, ensuring only one OAuth flow runs at a time (cancel any in-flight one first).
  2. Close stale browser tabs from previous sign-in attempts before retrying.
  3. Do not disable the check — it protects against CSRF; investigate how a foreign callback reached the local server instead.
  4. Verify no other application or flow is bound to the same callback port (CALLBACK_PORT) that could deliver mismatched state.

Example fix

// before
let creds = sign_in(&http_client, cx).await?;
// after
// ensure only one flow at a time
if let Some(inflight) = pending_sign_in.take() { inflight.abort(); }
let creds = sign_in(&http_client, cx).await
    .inspect_err(|e| if e.to_string().contains("state mismatch") {
        log::warn!("Stale or forged OAuth callback rejected; retry sign-in");
    })?;
Defensive patterns

Strategy: validation

Validate before calling

// before exchanging the code, confirm single-flow ownership of the callback port
let server_bound = std::net::TcpListener::bind((CALLBACK_HOST, CALLBACK_PORT)).is_err();
if !server_bound {
    return Err(anyhow!("another OAuth flow may be listening; stale callbacks possible"));
}

Type guard

fn state_matches(callback_state: &str, pkce_state: &str) -> bool {
    // constant-time comparison to avoid timing side channels
    callback_state.len() == pkce_state.len()
        && callback_state.bytes().zip(pkce_state.bytes()).fold(0u8, |acc, (a, b)| acc | (a ^ b)) == 0
}

Try / catch

match sign_in(&http_client, cx).await {
    Err(e) if e.to_string().contains("OAuth state mismatch") => {
        log::warn!("callback state mismatch (CSRF or stale tab); restarting flow");
        sign_in(&http_client, cx).await
    }
    other => other,
}

Prevention

When it happens

Trigger: The callback URL delivered to the local callback server carries a state parameter different from pkce.state — e.g. two sign-in flows ran concurrently and the browser hit the stale one, the user reloaded an old redirect URL, or a malicious page redirected to a forged callback.

Common situations: User opens the authorize URL twice (two tabs/windows) and completes the older flow; leftover browser tab from a previous session redirects to the port now owned by a new server instance; cross-site request forgery attempt against the localhost callback port.

Related errors


AI-assisted analysis of zed-industries/zed@916fc2b8cb (2026-09-19). Data as JSON: /api/errors/de3d3b4dc21d4142. Report an issue: GitHub.

Appendix: source

Thrown at crates/x_ai_subscribed/src/x_ai_subscribed.rs:820

            oauth_callback_server::OAuthCallbackServerConfig {
                host: CALLBACK_HOST,
                preferred_port: CALLBACK_PORT,
                fallback_port: None,
                path: CALLBACK_PATH,
            },
        )
        .context("Failed to start OAuth callback server")?;

    let pkce = new_pkce_authorize_request(redirect_uri)?;
    cx.update(|cx| cx.open_url(&pkce.authorize_url));

    let callback = callback_rx
        .await
        .map_err(|_| anyhow!("OAuth callback was cancelled"))?
        .context("OAuth callback failed")?;

    if callback.state != pkce.state {
        return Err(anyhow!("OAuth state mismatch"));
    }

    let tokens = exchange_code(
        &http_client,
        &callback.code,
        &pkce.verifier,
        &pkce.redirect_uri,
    )
    .await
    .context("Token exchange failed")?;

    let refresh_token = tokens
        .refresh_token
        .filter(|token| !token.is_empty())
        .context("Token response did not include a refresh_token")?;
    let email = tokens
        .id_token
        .as_deref()

View on GitHub (pinned to 916fc2b8cb)