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
- Close all stale authorize tabs and run a single fresh `ruflo auth login` — concurrent flows are the dominant cause
- If it persists, check for proxies/extensions interfering with localhost:PORT callbacks (state must round-trip untouched)
- Retry the login; a fresh flow generates a fresh state, so a one-off mismatch self-heals
- For headless/CI environments use `--token-stdin` or the manual code flow instead of the browser loopback flow
- 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
- Run one login flow at a time — concurrent loopback servers cause cross-talk
- Close stale authorize tabs before starting a new login
- Keep localhost callback URLs untouched by proxies and extensions
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
- authorization was denied or failed
- Cognitum auth service returned an unexpected response
- Cognitum refresh response did not contain an access token
- Could not reach the Cognitum auth service. ruflo core…
- Invalid or expired state parameter
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)