CVE Patch Review

GHSA-WXW3-Q3M9-C3JR: Root-cause Fix for OAuth Login CSRF in Better Auth

GHSA-WXW3-Q3M9-C3JR · Updated 2026-05-16 Root-cause

Summary

Better Auth previously stored OAuth flow state in cookies without binding the callback's state parameter to the issued nonce in cookie-backed, non-PKCE flows. The patch adds an explicit oauthState field to persisted state, validates it during callback processing, updates proxy handling, and adds regression tests for mismatched state. This addresses the core CSRF weakness rather than only filtering a single path.

Analysis

Vulnerability

GHSA-WXW3-Q3M9-C3JR describes a login CSRF condition in Better Auth's OAuth handling. The issue is specifically tied to cookie-backed state storage when PKCE is not in use: the callback processing path did not reliably verify that the incoming OAuth state parameter matched the nonce originally issued for that authorization flow. As a result, an attacker could cause a victim browser carrying the victim's cookies to complete an OAuth callback with attacker-controlled authorization context, binding the victim session to the wrong identity.

The patch sources show the root problem in packages/better-auth/src/state.ts: previously the serialized state payload was written as-is, without persisting a dedicated OAuth callback nonce for later comparison. The fix introduces an oauthState field and enforces comparison against the callback state value. This is consistent with the advisory's description and the changeset note in the commit and pull request.

// before
value: JSON.stringify(stateData),

// after
oauthState: z.string().optional(),
value: JSON.stringify({
  ...stateData,
  oauthState: state,
} satisfies StateData),

if (!parsedData.oauthState || parsedData.oauthState !== state) {
  throw new StateError(
    "State mismatch: OAuth state parameter does not match stored state",
    { code: "state_security_mismatch", details: { state } },
  );
}

Relevant sources: commit 9deb793, PR #8949, and the GitHub Security Advisory.

Patch

The patch makes three material changes.

First, it extends persisted state with an explicit oauthState property in packages/better-auth/src/state.ts. The state creation path now stores the generated OAuth nonce alongside the rest of the flow metadata instead of only serializing the prior state object.

Second, it adds validation logic that rejects callbacks when the stored oauthState is missing or does not equal the callback state. The snippets show two checks: one strict branch that throws when oauthState is absent or mismatched, and another conditional mismatch check for paths where the field is optional. Both converge on a StateError with code state_security_mismatch.

Third, the OAuth proxy path in packages/better-auth/src/plugins/oauth-proxy/index.ts now validates that stateData.oauthState matches statePackage.state and redirects with state_mismatch on failure. This matters because proxy-mediated flows are another place where state binding can be lost if only the main callback path is hardened.

if (
  stateData.oauthState !== undefined &&
  stateData.oauthState !== statePackage.state
) {
  ctx.context.logger.error("OAuth proxy state binding mismatch");
  throw redirectOnError(ctx, errorURL, "state_mismatch");
}

The regression coverage is also meaningful. The new test in packages/better-auth/src/plugins/generic-oauth/generic-oauth.test.ts exercises a cookie-backed OAuth flow, then invokes the callback with state=attacker-controlled-state and asserts a redirect containing state_mismatch and no established session. Additional test updates in packages/better-auth/src/social.test.ts assert that oauthState is present and bound to the generated state value. See the fixing commit and PR #8949.

Review

Pros

  • The patch addresses the actual trust-boundary failure: callback acceptance was not cryptographically or logically bound to the originally issued OAuth nonce in cookie-backed flows.
  • State generation and state verification are updated together in state.ts, which is the right place to enforce invariant preservation across flow stages.
  • The proxy path is also updated, reducing the chance of a bypass through alternate OAuth plumbing.
  • Regression tests are security-relevant rather than superficial: they assert rejection on mismatched state and verify that no session is created.
  • Error handling is explicit and observable via state_mismatch / state_security_mismatch, which should help operators diagnose failures.

Cons

  • The snippets do not fully show whether every OAuth callback entrypoint funnels through the same oauthState verification logic; review of surrounding code is still warranted for parity across providers and plugins.
  • The presence of both a strict missing-or-mismatch check and a separate conditional mismatch check suggests multiple code paths with slightly different semantics. That may be correct, but it increases maintenance complexity.
  • The test coverage shown is focused on cookie-backed flows and mismatch rejection. The provided snippets do not demonstrate compatibility tests for all state storage strategies or mixed PKCE/non-PKCE configurations.

Verdict

Root-cause.

This patch appears to fix the core vulnerability rather than masking symptoms. The defect was insufficient binding between the callback state parameter and the server-issued flow state under cookie storage. The remediation stores a dedicated oauthState nonce, validates it during callback handling, propagates the same invariant into the OAuth proxy path, and adds regression tests proving that attacker-controlled callback state no longer results in session creation. Based on the supplied commit, pull request, and advisory, this is a source-grounded root-cause fix for the described login CSRF issue.

References: commit 9deb793, PR #8949, GitHub Advisory, CVE Reports summary.

Sources