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
- 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
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
- 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
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
- Token exchange failed
- Computer use routing and storage are managed by AIRI.
- Extension entrypoint escapes the package folder
- Extension entrypoint resolves outside the package folder
- Extension folder import is available only from the…
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)