{"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":"exception","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/27111382b4a79a7e983289d6e983a06af185ed0f/packages/stage-ui/src/libs/auth-oidc.ts#L63-L99","documentation":"Thrown by exchangeCodeForTokens() when the `state` parameter echoed back in the OAuth2/OIDC callback does not match the state persisted before the authorization redirect. Per RFC 6749 S10.12, the state parameter binds the callback to the flow that started it; a mismatch signals the response may be forged (CSRF). This is a hard security stop — no token exchange is attempted.","triggerScenarios":"consumeFlowState() returned a flowState whose `.state` differs from the `returnedState` query param on the callback URL. Caused by: sessionStorage cleared/lost between redirect and callback, two concurrent OAuth flows overwriting FLOW_STATE_KEY, a bookmarked/shared callback URL from a different flow, or an actual CSRF injection.","commonSituations":"User opened the app in a different tab/session (private mode, different browser) for the callback. sessionStorage was wiped mid-flow (browser settings, tab close/reopen). User started a second sign-in while the first redirect was in flight, overwriting the persisted state. A malicious or stale link was followed.","solutions":["Restart the OIDC flow from buildAuthorizationURL() so a fresh state+verifier pair is persisted, then complete the callback.","Ensure sessionStorage is not cleared between the authorize redirect and the callback (no aggressive cleanup, no private-mode split).","Prevent concurrent flows: clear FLOW_STATE_KEY only on the matching callback, and serialize link/sign-in attempts.","If the mismatch is reproducible with a single flow, check that the callback reads state from the same sessionStorage scope that persistFlowState wrote (same origin, same tab)."],"exampleFix":null,"handlingStrategy":"validation","validationCode":"import { consumeFlowState } from './auth-oidc'\n// on the callback route, before exchangeCodeForTokens:\nconst stored = consumeFlowState()\nif (!stored) {\n  // no persisted flow — abort; restart from buildAuthorizationURL()\n  throw new Error('No persisted OIDC flow state; restart sign-in')\n}\nif (returnedState !== stored.flowState.state) {\n  // do NOT call exchangeCodeForTokens; treat as invalid/CSRF\n}","typeGuard":"function isValidCallbackState(\n  returnedState: string,\n  flow: { state: string } | null,\n): flow is { state: string } {\n  return !!flow && returnedState === flow.state\n}","tryCatchPattern":"// exchangeCodeForTokens throws on mismatch by design; catch at the caller\ntry {\n  await exchangeCodeForTokens(code, flowState, params, returnedState)\n}\ncatch (err) {\n  if (err instanceof Error && err.message.includes('state mismatch')) {\n    // discard flow state, restart authorization; never proceed with token exchange\n  }\n  else throw err\n}","preventionTips":["Persist flow state via persistFlowState() immediately after buildAuthorizationURL().","Do not start a second OAuth flow while one is pending (avoid overwriting FLOW_STATE_KEY).","Do not share/bookmark callback URLs; the state must match the originating flow."],"tags":["security","oidc","oauth","csrf","auth","state-mismatch"],"backgroundTag":null,"analyzedSha":"27111382b4a79a7e983289d6e983a06af185ed0f","analyzedAt":"2026-08-12T18:33:34.132Z","schemaVersion":2},"datasetVersion":"2026-08-12T23:17:12.415Z"}