GHSA-WXW3-Q3M9-C3JR: Root-cause Fix for OAuth Login CSRF in Better Auth
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
oauthStateverification 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.
Recommended Labs
Try this vulnerability pattern yourself with hands-on labs.
- CSRF.js
Best direct match for the advisory topic. The Better Auth issue is a login CSRF caused by insufficient OAuth state validation, and this lab maps to CWE-352 / OWASP A01:2021. It is hands-on and defensive, making it useful for practicing anti-CSRF controls, request integrity checks, and callback-flow validation patterns relevant to OAuth login handlers.
- OAuth.py
Strongly relevant because the root cause involves OAuth callback handling and trust in authentication flow state. While not labeled strictly as CSRF, this lab focuses on OAuth implementation security and is useful for learning how to validate stateful auth exchanges, reduce login flow abuse, and harden callback processing.
- Bad token.js
A good supporting lab for this topic because insufficient OAuth state verification often overlaps with weak token/session integrity assumptions. This lab helps reinforce defensive patterns around signed tokens, session binding, and preventing authentication confusion bugs that can combine with login CSRF-style weaknesses.