{"record":{"id":"bb9e85dc80412922","repo":"tinyhumansai/openhuman","slug":"openrouter-oauth-callback-state-did-not-match-the","errorCode":null,"errorMessage":"OpenRouter OAuth callback state did not match the request.","messagePattern":"OpenRouter OAuth callback state did not match the request\\.","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"app/src/utils/openrouterOAuth.ts","lineNumber":55,"sourceCode":"  return btoa(binary).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/g, '');\n}\n\nasync function createCodeChallenge(verifier: string): Promise<string> {\n  const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));\n  return base64UrlEncode(new Uint8Array(digest));\n}\n\nfunction extractOAuthCode(callbackUrl: string, expectedState: string): string {\n  let parsed: URL;\n  try {\n    parsed = new URL(callbackUrl);\n  } catch {\n    throw new Error('OpenRouter OAuth returned an invalid callback URL.');\n  }\n\n  const actualState = parsed.searchParams.get('state');\n  if (actualState !== expectedState) {\n    throw new Error('OpenRouter OAuth callback state did not match the request.');\n  }\n\n  const code = parsed.searchParams.get('code');\n  if (!code) {\n    throw new Error('OpenRouter OAuth did not return an authorization code.');\n  }\n  return code;\n}\n\nasync function exchangeCodeForKey(\n  code: string,\n  verifier: string,\n  fetchImpl: typeof fetch\n): Promise<string> {\n  const response = await fetchImpl(OPENROUTER_TOKEN_URL, {\n    method: 'POST',\n    headers: { 'Content-Type': 'application/json' },\n    body: JSON.stringify({ code, code_verifier: verifier, code_challenge_method: PKCE_METHOD }),","sourceCodeStart":37,"sourceCodeEnd":73,"githubUrl":"https://github.com/tinyhumansai/openhuman/blob/a221052e0df5b1f7598fceba7329fd1af95d6699/app/src/utils/openrouterOAuth.ts#L37-L73","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","solutions":["Close prior authorize tabs before retrying the flow","Serialize attempts — disable the OAuth trigger while a flow is in flight","On mismatch, ignore that callback and keep listening for the one carrying the current state, with an overall timeout"],"exampleFix":"// before — first captured request decides\nconst url = await listener.next();\nconst code = extractOAuthCode(url, state);\n\n// after — skip stale callbacks, wait for the matching one\nfor await (const url of listener) {\n  const u = new URL(url);\n  if (u.searchParams.get('state') !== state) continue;\n  var code = extractOAuthCode(url, state);\n  break;\n}","handlingStrategy":"try-catch","validationCode":"const params = new URL(callbackUrl).searchParams;\n// state presence can be checked; equality must still be enforced by extractOAuthCode\nif (!params.has('state')) continue;","typeGuard":null,"tryCatchPattern":"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.","preventionTips":["Allow only one OAuth flow in flight — gate the trigger UI","Generate state per attempt and discard callbacks after the flow is cancelled","Tell users to close stale authorize tabs before retrying"],"tags":["oauth","security","csrf","state","openrouter"],"backgroundTag":null,"analyzedSha":"a221052e0df5b1f7598fceba7329fd1af95d6699","analyzedAt":"2026-08-16T12:47:06.542Z","schemaVersion":2},"datasetVersion":"2026-08-16T13:17:31.715Z"}