ruvnet/ruflo · error · StateMismatchError

state mismatch — the OAuth callback did not match the…

Error message

state mismatch — the OAuth callback did not match the request this CLI sent

What it means

StateMismatchError is the OAuth CSRF guard: the loopback callback arrived with a state parameter that doesn't equal the random state generated for THIS login attempt (or missing entirely). It is thrown by browserLogin after validateCallback returns reason 'state-mismatch'. The check is order-sensitive — an error param or missing code would have thrown LoginDeniedError instead — so reaching this error means a code was presented but the state didn't match.

Solutions

  1. Close all stale authorize tabs and run a single fresh `ruflo auth login` — concurrent flows are the dominant cause
  2. If it persists, check for proxies/extensions interfering with localhost:PORT callbacks (state must round-trip untouched)
  3. Retry the login; a fresh flow generates a fresh state, so a one-off mismatch self-heals
  4. For headless/CI environments use `--token-stdin` or the manual code flow instead of the browser loopback flow
  5. If you're implementing your own callback handler, ensure it forwards state verbatim to server.awaitCallback

Example fix

// before — stale tab satisfies a new flow's server
// (two logins racing on the same loopback port)

// after — serialize logins and surface a clear retry
try {
  const result = await browserLogin(print);
} catch (e) {
  if (e instanceof StateMismatchError) {
    print('Stale login detected — close old browser tabs and re-run ruflo auth login.');
    process.exitCode = 1;
  } else throw e;
}
Defensive patterns

Strategy: try-catch

Validate before calling

// before awaiting the callback, pin the expected state and reject foreign ones:
const expected = pkce.state;
const result = await server.awaitCallback();
if (result.state !== expected) throw new StateMismatchError(); // pre-check mirrors the library

Try / catch

import { StateMismatchError } from './auth/client.js';

try { await browserLogin(print); }
catch (e) {
  if (e instanceof StateMismatchError) {
    // tell user to close stale tabs and re-run once; do NOT auto-retry in a loop
  }
  throw e;
}

Prevention

When it happens

Trigger: browserLogin() callbacks hitting the wrong loopback server: two concurrent `ruflo auth login` runs sharing a port, a stale browser tab completing an older authorize request after a new one started, a proxy/extension rewriting callback query params, or the callback's state being dropped by a redirect chain.

Common situations: User re-runs login while the first browser tab is still open, then authorizes the old tab; port reuse across CLI invocations; corporate proxies or security software stripping query parameters from localhost callbacks; clock-skewed test harnesses replaying captured callback URLs.

Related errors


AI-assisted analysis of ruvnet/ruflo@fa13ee4ad6 (2026-08-18). Data as JSON: /api/errors/1ba2a262fee66197. Report an issue: GitHub.

Appendix: source

Thrown at v3/@claude-flow/cli/src/auth/client.ts:132

  }
}

/** Browser-based loopback PKCE login — the ADR-306 default for an interactive desktop. */
export async function browserLogin(print: (line: string) => void): Promise<LoginResult> {
  const sec = await loadSecurityOAuth();
  const server = await sec.CallbackServer.bind();
  const pkce = sec.generatePkce();
  const url = sec.authorizeUrl(server.redirectUri, pkce.state, pkce.codeChallenge);

  print('Opening your browser to sign in to Cognitum...');
  print(`If it doesn't open automatically, visit:\n\n  ${url}\n`);
  await sec.openBrowser(url).catch(() => {}); // best-effort — the URL above is always the fallback
  print('Waiting for you to finish signing in...');

  const result = await server.awaitCallback();
  const validated = validateCallback(result.error, result.code, result.state, pkce.state);
  if (!validated.ok) {
    if (validated.reason === 'state-mismatch') throw new StateMismatchError();
    throw new LoginDeniedError(validated.detail ?? 'unknown');
  }

  const tokens = await sec.exchangeCode(validated.code, pkce.codeVerifier, server.redirectUri);
  return { tokens, method: 'pkce' };
}

/** Headless fallback: prints the authorize URL with the OOB redirect, prompts for the pasted code. */
export async function manualLogin(
  print: (line: string) => void,
  input: NodeJS.ReadableStream = process.stdin,
): Promise<LoginResult> {
  const sec = await loadSecurityOAuth();
  print('Browser-based callback unavailable (SSH/container detected, or --no-browser).\n');

  const pkce = sec.generatePkce();
  const url = sec.authorizeUrl(sec.OOB_REDIRECT_URI, pkce.state, pkce.codeChallenge);
  print(`Open this URL in a browser and authorize:\n\n  ${url}\n`);

View on GitHub (pinned to fa13ee4ad6)