moeru-ai/airi · critical · Error

OIDC state mismatch — possible CSRF attack

Error message

OIDC state mismatch — possible CSRF attack

What it means

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.

Solutions

  1. Restart the login flow from the entry point so a fresh state and PKCE verifier pair are generated
  2. Clear stored OIDC flow state after every completed or failed exchange
  3. Prevent concurrent flow starts — disable the sign-in button while a flow is pending
  4. Keep flow state in the same storage scope that survives the redirect, keyed per flow

Example fix

// before
const tokens = await exchangeCodeForTokens(code, flowState, params, urlState)

// after
if (urlState !== flowState.state) {
  clearStoredFlowState()
  window.location.assign(buildAuthorizeUrl(newFlowState)) // fresh state + verifier
}
const tokens = await exchangeCodeForTokens(code, flowState, params, urlState)
Defensive patterns

Strategy: validation

Validate before calling

const urlState = new URL(window.location.href).searchParams.get('state')
if (!urlState || urlState !== flowState.state) {
  clearStoredFlowState()
  restartLogin()
}

Try / catch

try {
  await exchangeCodeForTokens(code, flowState, params, urlState)
}
catch (err) {
  if (err.message.includes('state mismatch')) {
    // Never retry the exchange with mismatched state — restart the flow.
    clearStoredFlowState()
    restartLogin()
  }
}

Prevention

When it happens

Trigger: 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.

Common situations: 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.

Related errors


AI-assisted analysis of moeru-ai/airi@677329427f (2026-08-18). Data as JSON: /api/errors/11d091e8ce473911. Report an issue: GitHub.

Appendix: source

Thrown at packages/stage-ui/src/libs/auth-oidc.ts:81

  expires_in: number
  refresh_token?: string
  id_token?: string
  scope?: string
}

/**
 * Exchange an authorization code for tokens (RFC 6749 S4.1.3).
 * Pure function — does NOT write to any store. Caller is responsible
 * for persisting the returned tokens.
 */
export async function exchangeCodeForTokens(
  code: string,
  flowState: OIDCFlowState,
  params: OIDCFlowParams,
  returnedState: string,
): Promise<TokenResponse> {
  if (returnedState !== flowState.state)
    throw new Error('OIDC state mismatch — possible CSRF attack')

  const bodyParams: Record<string, string> = {
    grant_type: 'authorization_code',
    code,
    redirect_uri: params.redirectUri,
    client_id: params.clientId,
    code_verifier: flowState.codeVerifier,
    resource: SERVER_URL,
  }

  // Confidential clients must send the secret during token exchange.
  if (params.clientSecret)
    bodyParams.client_secret = params.clientSecret

  const body = new URLSearchParams(bodyParams)

  const response = await fetch(new URL(OIDC_TOKEN_PATH, SERVER_URL), {
    method: 'POST',

View on GitHub (pinned to 677329427f)