moeru-ai/airi · critical · Error

OIDC state mismatch — possible CSRF attack

Error message

OIDC state mismatch — possible CSRF attack

What it means

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.

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 27111382b4)

Solutions

  1. Restart the OIDC flow from buildAuthorizationURL() so a fresh state+verifier pair is persisted, then complete the callback.
  2. Ensure sessionStorage is not cleared between the authorize redirect and the callback (no aggressive cleanup, no private-mode split).
  3. Prevent concurrent flows: clear FLOW_STATE_KEY only on the matching callback, and serialize link/sign-in attempts.
  4. 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).
Defensive patterns

Strategy: validation

Validate before calling

import { consumeFlowState } from './auth-oidc'
// on the callback route, before exchangeCodeForTokens:
const stored = consumeFlowState()
if (!stored) {
  // no persisted flow — abort; restart from buildAuthorizationURL()
  throw new Error('No persisted OIDC flow state; restart sign-in')
}
if (returnedState !== stored.flowState.state) {
  // do NOT call exchangeCodeForTokens; treat as invalid/CSRF
}

Type guard

function isValidCallbackState(
  returnedState: string,
  flow: { state: string } | null,
): flow is { state: string } {
  return !!flow && returnedState === flow.state
}

Try / catch

// exchangeCodeForTokens throws on mismatch by design; catch at the caller
try {
  await exchangeCodeForTokens(code, flowState, params, returnedState)
}
catch (err) {
  if (err instanceof Error && err.message.includes('state mismatch')) {
    // discard flow state, restart authorization; never proceed with token exchange
  }
  else throw err
}

Prevention

When it happens

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

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

Related errors


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