tinyhumansai/openhuman · error · Error

OpenRouter OAuth callback state did not match the request.

Error message

OpenRouter OAuth callback state did not match the request.

What it means

OAuth CSRF protection: the state query parameter in the callback must equal the random state sent with the authorize request. A mismatch means the callback belongs to a different flow instance — a stale tab, a second concurrent attempt, or an injected/forged redirect.

Source

Thrown at app/src/utils/openrouterOAuth.ts:55

  return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}

async function createCodeChallenge(verifier: string): Promise<string> {
  const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
  return base64UrlEncode(new Uint8Array(digest));
}

function extractOAuthCode(callbackUrl: string, expectedState: string): string {
  let parsed: URL;
  try {
    parsed = new URL(callbackUrl);
  } catch {
    throw new Error('OpenRouter OAuth returned an invalid callback URL.');
  }

  const actualState = parsed.searchParams.get('state');
  if (actualState !== expectedState) {
    throw new Error('OpenRouter OAuth callback state did not match the request.');
  }

  const code = parsed.searchParams.get('code');
  if (!code) {
    throw new Error('OpenRouter OAuth did not return an authorization code.');
  }
  return code;
}

async function exchangeCodeForKey(
  code: string,
  verifier: string,
  fetchImpl: typeof fetch
): Promise<string> {
  const response = await fetchImpl(OPENROUTER_TOKEN_URL, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ code, code_verifier: verifier, code_challenge_method: PKCE_METHOD }),

View on GitHub (pinned to a221052e0d)

Solutions

  1. Close prior authorize tabs before retrying the flow
  2. Serialize attempts — disable the OAuth trigger while a flow is in flight
  3. On mismatch, ignore that callback and keep listening for the one carrying the current state, with an overall timeout

Example fix

// before — first captured request decides
const url = await listener.next();
const code = extractOAuthCode(url, state);

// after — skip stale callbacks, wait for the matching one
for await (const url of listener) {
  const u = new URL(url);
  if (u.searchParams.get('state') !== state) continue;
  var code = extractOAuthCode(url, state);
  break;
}
Defensive patterns

Strategy: try-catch

Validate before calling

const params = new URL(callbackUrl).searchParams;
// state presence can be checked; equality must still be enforced by extractOAuthCode
if (!params.has('state')) continue;

Try / catch

Catch the state mismatch, do NOT fail the flow — keep listening for a callback whose state equals the current attempt's, bounded by an overall timeout; cancel cleanly if none arrives.

Prevention

When it happens

Trigger: The user completes authorization in a browser tab from an earlier attempt while a new listener with a fresh state is running; two OAuth flows triggered concurrently; a deep-link callback replayed manually.

Common situations: Duplicate tabs left open after a failed attempt; retrying OAuth while the old tab still redirects; listener restarted with a new state while the browser holds the old authorize URL.

Related errors


AI-assisted analysis of tinyhumansai/openhuman@a221052e0d (2026-08-16). Data as JSON: /api/errors/bb9e85dc80412922. Report an issue: GitHub.