{"record":{"id":"11d091e8ce473911","repo":"moeru-ai/airi","slug":"oidc-state-mismatch-possible-csrf-attack","errorCode":null,"errorMessage":"OIDC state mismatch — possible CSRF attack","messagePattern":"OIDC state mismatch — possible CSRF attack","errorType":"validation","errorClass":"Error","httpStatus":null,"severity":"critical","filePath":"packages/stage-ui/src/libs/auth-oidc.ts","lineNumber":81,"sourceCode":"  expires_in: number\n  refresh_token?: string\n  id_token?: string\n  scope?: string\n}\n\n/**\n * Exchange an authorization code for tokens (RFC 6749 S4.1.3).\n * Pure function — does NOT write to any store. Caller is responsible\n * for persisting the returned tokens.\n */\nexport async function exchangeCodeForTokens(\n  code: string,\n  flowState: OIDCFlowState,\n  params: OIDCFlowParams,\n  returnedState: string,\n): Promise<TokenResponse> {\n  if (returnedState !== flowState.state)\n    throw new Error('OIDC state mismatch — possible CSRF attack')\n\n  const bodyParams: Record<string, string> = {\n    grant_type: 'authorization_code',\n    code,\n    redirect_uri: params.redirectUri,\n    client_id: params.clientId,\n    code_verifier: flowState.codeVerifier,\n    resource: SERVER_URL,\n  }\n\n  // Confidential clients must send the secret during token exchange.\n  if (params.clientSecret)\n    bodyParams.client_secret = params.clientSecret\n\n  const body = new URLSearchParams(bodyParams)\n\n  const response = await fetch(new URL(OIDC_TOKEN_PATH, SERVER_URL), {\n    method: 'POST',","sourceCodeStart":63,"sourceCodeEnd":99,"githubUrl":"https://github.com/moeru-ai/airi/blob/677329427f32468c74b17f3ec47eeca4e05bec65/packages/stage-ui/src/libs/auth-oidc.ts#L63-L99","documentation":"exchangeCodeForTokens compares the state query parameter returned by the authorization server with flowState.state captured when the flow started; per RFC 6749 S10.12 a mismatch means the callback was not produced by this flow — classically CSRF, but in practice usually stale or cross-tab flow state. The function is pure and throws before the token POST; the caller owns the persisted flowState.","triggerScenarios":"Stored flow state is from a previous login attempt because state was never cleared after a completed or failed exchange; a second tab started a new flow and overwrote the stored state; the callback opens in a context with different storage (sessionStorage lost after browser restart); the user re-visits a bookmarked callback URL.","commonSituations":"User retries an old login link; double-clicking Sign In spawns two flows; a router hook processes the callback twice while state was regenerated in between; state kept in sessionStorage but the redirect lands in a fresh session.","solutions":["Restart the login flow from the entry point so a fresh state and PKCE verifier pair are generated","Clear stored OIDC flow state after every completed or failed exchange","Prevent concurrent flow starts — disable the sign-in button while a flow is pending","Keep flow state in the same storage scope that survives the redirect, keyed per flow"],"exampleFix":"// before\nconst tokens = await exchangeCodeForTokens(code, flowState, params, urlState)\n\n// after\nif (urlState !== flowState.state) {\n  clearStoredFlowState()\n  window.location.assign(buildAuthorizeUrl(newFlowState)) // fresh state + verifier\n}\nconst tokens = await exchangeCodeForTokens(code, flowState, params, urlState)","handlingStrategy":"validation","validationCode":"const urlState = new URL(window.location.href).searchParams.get('state')\nif (!urlState || urlState !== flowState.state) {\n  clearStoredFlowState()\n  restartLogin()\n}","typeGuard":null,"tryCatchPattern":"try {\n  await exchangeCodeForTokens(code, flowState, params, urlState)\n}\ncatch (err) {\n  if (err.message.includes('state mismatch')) {\n    // Never retry the exchange with mismatched state — restart the flow.\n    clearStoredFlowState()\n    restartLogin()\n  }\n}","preventionTips":["Generate a fresh cryptographically random state per authorize request","Delete flow state immediately after exchange, on success or failure","Serialize logins: refuse to start a flow while one is pending","Never bookmark or replay callback URLs"],"tags":["oidc","oauth","csrf","security","state"],"backgroundTag":"oauth-state-mismatch","analyzedSha":"677329427f32468c74b17f3ec47eeca4e05bec65","analyzedAt":"2026-08-18T17:29:58.153Z","contentChangedAt":"2026-08-18T17:29:58.153Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}