{"record":{"id":"39ba11476446f917","repo":"vercel/ai","slug":"oauth-state-parameter-mismatch-possible-csrf-att","errorCode":null,"errorMessage":"OAuth state parameter mismatch - possible CSRF attack","messagePattern":"OAuth state parameter mismatch - possible CSRF attack","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"packages/mcp/src/tool/oauth.ts","lineNumber":1350,"sourceCode":"        ...clientMetadata,\n        scope: selectedScope,\n      },\n      fetchFn,\n    });\n\n    clientInformation = addAuthorizationServerInformationToClientInformation(\n      fullInformation,\n      currentAuthorizationServerInformation,\n    );\n    await provider.saveClientInformation(clientInformation);\n  }\n\n  /** On callback, validate state and AS pin before code exchange */\n  if (authorizationCode !== undefined) {\n    if (provider.storedState) {\n      const expectedState = await provider.storedState();\n      if (expectedState !== undefined && expectedState !== callbackState) {\n        throw new Error(\n          'OAuth state parameter mismatch - possible CSRF attack',\n        );\n      }\n    }\n\n    const storedAuthorizationServerInformation =\n      await getStoredAuthorizationServerInformation({\n        provider,\n        clientInformation,\n      });\n    if (!storedAuthorizationServerInformation) {\n      throw new MCPClientOAuthError({\n        message:\n          'Stored OAuth authorization server metadata is required when exchanging an authorization code',\n      });\n    }\n    validateAuthorizationResponseIssuer({\n      callbackIssuer,","sourceCodeStart":1332,"sourceCodeEnd":1368,"githubUrl":"https://github.com/vercel/ai/blob/69428b1f8b037e4d118fb4853428d5c4e620493c/packages/mcp/src/tool/oauth.ts#L1332-L1368","documentation":"On the OAuth callback, the state query parameter returned by the authorization server is compared with the state previously stored via provider.storedState(). A mismatch means the callback did not originate from an authorization request this client started — the classic signature of a CSRF attack on the OAuth redirect — so the code exchange is aborted.","triggerScenarios":"auth() is called with an authorizationCode (callback phase) and provider.storedState() returns a value that differs from the callbackState extracted from the redirect URL. Also fires if state was consumed/cleared and a stale or attacker-forged callback is replayed, or two concurrent OAuth flows overwrite each other's state.","commonSituations":"Users clicking an old callback link, browser tab A finishing auth while tab B restarted the flow, server restarts losing stored state, reverse proxies stripping/altering the state query param, or actual CSRF probing of the redirect endpoint.","solutions":["Restart the OAuth flow: clear stored state, call auth() again without an authorizationCode to get a fresh authorization URL, and complete the redirect in the same session.","Ensure storedState() is backed by persistent, per-flow storage (cookie, session, file) and is only cleared after a successful token exchange.","Check that nothing rewrites the redirect URL query string (proxies, redirects) and that only one OAuth flow runs per user session at a time."],"exampleFix":"// before\nconst url = new URL(callbackUrl);\nawait auth(authorizationServerUrl, { ...provider, /* state ignored */ });\n// after\nconst state = new URL(callbackUrl).searchParams.get('state');\nconst expected = await provider.storedState();\nif (expected !== undefined && expected !== state) {\n  // restart flow rather than retrying the callback\n  return startAuthorization(authorizationServerUrl, provider);\n}","handlingStrategy":"try-catch","validationCode":"const state = new URL(callbackUrl).searchParams.get('state');\nconst expected = await provider.storedState?.();\nconst looksValid = expected === undefined || expected === state;","typeGuard":null,"tryCatchPattern":"try {\n  await auth(serverUrl, { ...provider, authorizationCode });\n} catch (e) {\n  if (e instanceof Error && e.message.includes('state parameter mismatch')) {\n    // treat as CSRF/replay: clear stored state, restart the authorization flow\n    await provider.clearState?.();\n    return startNewAuthorization(serverUrl, provider);\n  }\n  throw e;\n}","preventionTips":["Keep one OAuth flow per user session at a time","Store state in durable, session-scoped storage and delete it only after success","Don't bookmark or replay callback URLs","Ensure proxies don't rewrite the redirect query string"],"tags":["oauth","security","csrf","state-mismatch"],"backgroundTag":"oauth-state-mismatch","analyzedSha":"69428b1f8b037e4d118fb4853428d5c4e620493c","analyzedAt":"2026-08-30T12:32:21.016Z","schemaVersion":2},"datasetVersion":"2026-08-30T13:17:10.514Z"}