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
- 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).
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
- 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.
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
- Token exchange failed: ${response.status} ${error}
- Token refresh failed: ${response.status}
- Token exchange failed (${response.status}): ${text}
- Unexpected response
- link failed
AI-assisted analysis of moeru-ai/airi@27111382b4 (2026-08-12).
Data as JSON: /api/errors/11d091e8ce473911.
Report an issue: GitHub.