Skip to content

chore(deps): update dependency better-auth to v1.6.13 [security] - #75

Open
renovate[bot] wants to merge 1 commit into
devfrom
renovate/npm-better-auth-vulnerability
Open

chore(deps): update dependency better-auth to v1.6.13 [security]#75
renovate[bot] wants to merge 1 commit into
devfrom
renovate/npm-better-auth-vulnerability

Conversation

@renovate

@renovate renovate Bot commented May 15, 2026

Copy link
Copy Markdown
Contributor

ℹ️ Note

This PR body was truncated due to platform limits.

This PR contains the following updates:

Package Change Age Confidence
better-auth (source) 1.5.31.6.13 age confidence

Better Auth: OAuth callback accepts mismatched state when cookie-backed state storage is used without PKCE

GHSA-wxw3-q3m9-c3jr

More information

Details

Am I affected?

Users are affected if all of the following are true:

  • The application uses better-auth at a version below 1.6.2 (or @better-auth/sso paired with such a version).
  • betterAuth({ account: { storeStateStrategy } }) is set to "cookie". The default "database" is not affected.
  • The application wires at least one OAuth provider through genericOAuth({ config }) with pkce: false, or it supplies a custom getToken or tokenUrl that does not require the stored codeVerifier. Stock social providers with PKCE on are not affected.
  • The provider returns arbitrary code values to the configured callback URL.

If users are on better-auth@1.6.2 or later, they are not affected.

Fix:

  1. Upgrade to better-auth@1.6.2 or later (current stable is 1.6.10).
  2. If users cannot upgrade, see workarounds below.
Summary

In parseGenericState, the cookie branch decrypted the oauth_state cookie and validated expiry, but did not compare the incoming OAuth state query parameter to the nonce that generateGenericState issued at sign-in. Any callback to /api/auth/oauth2/callback/<providerId> that arrived with a forged state and any code was therefore accepted as long as the browser still held a live oauth_state cookie. With pkce: false (or any getToken path that does not enforce a code-verifier round-trip), an attacker who forced the victim to deliver an attacker-controlled authorization code to the callback would mint a session bound to the attacker's external identity in the victim's browser. Account-linking flows behaved the same way, binding the attacker's external account to an authenticated victim row.

Details

The cookie branch of parseGenericState did not compare the cookie's stored nonce to the incoming state parameter. The database branch (the default) was not affected because the verification row is keyed by state and the lookup itself enforces equality.

The fix re-binds the cookie to the nonce: generateGenericState writes oauthState: state into the encrypted payload before storage, and parseGenericState rejects when parsedData.oauthState !== state. The same primitive covers every caller (generic-oauth, social, account-link, oauth-proxy passthrough, OIDC SSO, SAML relay state).

Patches

Fixed in better-auth@1.6.2 via PR #​8949 (commit 9deb7936a, merged 2026-04-09). The cookie branch of parseGenericState now rejects when the encrypted payload's nonce does not match the incoming state parameter; the database branch gained a defense-in-depth equality check.

Workarounds

If users cannot upgrade immediately:

  • Switch storeStateStrategy back to "database" (the default). This closes the cookie-only bypass without a code change.
  • Enable pkce: true on every affected genericOAuth provider. The codeVerifier is the missing primitive that the attacker cannot supply.
Impact
  • Forced-login (CSRF on OAuth callback): the attacker forces the victim's browser into an authenticated session bound to the attacker's external identity, allowing the attacker to observe the victim's actions inside the application.
  • Persistent account linking: account-link flows bind the attacker's external account to the victim's authenticated row, granting persistent access until the link is removed.
Credit

Reported by @​Jvr2022 via private advisory disclosure, and by @​alavesa (PatchPilots audit) via the public duplicate issue #​8897.

Resources

Severity

  • CVSS Score: 5.3 / 10 (Medium)
  • Vector String: CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:N/I:H/A:N

References

This data is provided by the GitHub Advisory Database (CC-BY 4.0).


Better Auth: OAuth refresh-token rotation forks the token family on concurrent redemption

CVE-2026-53517 / GHSA-392p-2q2v-4372

More information

Details

Am I affected?

Users are affected if all of the following are true:

  • Their project depends on @better-auth/oauth-provider at a version >= 1.6.0, < 1.6.11, or uses the embedded plugin in better-auth >= 1.4.8-beta.7, < 1.6.0.
  • At least one OAuth client served by their application's authorization server requests the offline_access scope, so refresh tokens are minted.
  • Concurrent redemption of the same refresh token is reachable: an SPA shares one refresh token across browser tabs without a mutex, a mobile client retries after a transient failure, an attacker who has stolen a refresh token times two requests, or a service worker queues offline requests.

If developer applications do not request offline_access for any client, no refresh tokens are minted and they are not exposed.

Fix:

  1. Upgrade to @better-auth/oauth-provider@1.6.11 or later.
  2. If developers cannot upgrade, see workarounds below.
Summary

The OAuth provider's POST /oauth2/token endpoint, on the refresh_token grant, performs a non-atomic read / validate / revoke / mint sequence on the oauthRefreshToken row. Two concurrent requests presenting the same parent refresh token both pass the revocation check before either revoke completes, so each mints a fresh refresh token. The replay-detection branch only fires when revoked is already truthy at read time, which is exactly the state concurrent attackers race past. The result is a forked refresh-token family from a single parent token.

Details

The adapter.update predicate on the parent row is keyed on id only; it does not include revoked IS NULL, so two concurrent updates both succeed (last-write-wins, no error path). The schema does not declare unique on oauthRefreshToken.token, so concurrent creates do not collide on a unique-key violation either.

RFC 9700 §4.14 (OAuth Security Best Current Practice) prescribes refresh-token family invalidation on detected reuse; this implementation tries to enforce that contract through the revoked check, but the check is not atomic with the consumption step. Token rotation issues a new refresh token with each call, so a single stolen refresh token grants indefinite access until the row is revoked or its refreshTokenExpiresAt (default 7 days) passes. Rotation refreshes that window each call.

The fix lands an atomic compare-and-swap on the parent row inside the rotation primitive (UPDATE ... WHERE id = ? AND revoked IS NULL with a rowcount check), so the losing rotation fails closed with invalid_grant and the parent row stays marked revoked. Subsequent replay of the original refresh token then trips the existing family-invalidation guard. The schema gains a unique constraint on oauthRefreshToken.token for parity with oauthAccessToken.token.

Patches

Fixed in @better-auth/oauth-provider@1.6.11. The refresh-token rotation primitive now performs an atomic compare-and-swap on the parent row, and the explicit revokeRefreshToken path uses the same CAS. On a contested rotation, exactly one caller wins and mints a fresh refresh token; the loser receives invalid_grant. Subsequent replay of the original refresh token trips the existing family-invalidation guard because the parent row stays marked revoked.

@better-auth/memory-adapter@1.6.11 ships a compatibility fix in the same wave: the in-memory where clause now treats undefined and null as equivalent under an eq null predicate, mirroring SQL IS NULL and Mongo's missing-or-null semantics. Without this change, the CAS predicate WHERE revoked IS NULL falls through on every call against a row whose optional revoked field is absent (the adapter factory's transformInput skips writing undefined when no default exists), so the rotation above is broken for any deployment using the in-memory adapter.

Strict refresh-token family invalidation on a contested rotation, per RFC 9700 §4.14 (which calls for invalidating the winner's tokens too when reuse is detected at rotation time), is deferred to a follow-up minor on the next channel. Closing it cleanly requires an opt-in transactional rotation in the adapter contract so the family-delete cannot interleave with the winner's in-flight access-token insert. The deferred site carries a FIXME(strict-family-invalidation) marker.

Schema-migration note: the better-auth migration generator only emits UNIQUE for newly-created columns. Existing installs will not pick up the new oauthRefreshToken.token unique constraint from migrate / generate; add it manually if an application's operational tooling depends on it (CREATE UNIQUE INDEX oauth_refresh_token_token_uniq ON "oauthRefreshToken" (token);). The CAS fix above does not depend on the database-level constraint to be correct; the constraint is defense-in-depth so collisions from a buggy custom generateRefreshToken callback fail loudly.

Workarounds

None of these close the bug fully without a code patch.

  • Adapter-level: configure the database adapter to run the OAuth refresh handler under serializable isolation, or wrap the adapter.update on oauthRefreshToken with a row-level pessimistic lock (SELECT ... FOR UPDATE). Narrows the window without closing it.
  • Token lifetime: pass oauthProvider({ refreshTokenExpiresIn: 60 }) to expire forked families within one minute. Trades attacker persistence for shorter user sessions.
  • Client-side single-flight: serialize refresh-token usage in the client SDK with a mutex. Mitigates honest concurrency but does nothing against an attacker with a stolen refresh token.
  • Disable refresh tokens: do not request the offline_access scope. Closes the surface but breaks long-lived sessions.
Impact
  • Indefinite access from a single stolen refresh token: forked refresh-token families grant access at the original user's authorization scope, surviving past any single revocation if an attacker holds any branch.
  • Detection bypass: legitimate users whose refresh token has been forked do not trip family invalidation when they refresh, because the attacker's branch already swapped the parent row out from under the legitimate user's check.
Credit

Reported by @​chdanielmueller.

Resources

Severity

  • CVSS Score: 7.6 / 10 (High)
  • Vector String: CVSS:4.0/AV:N/AC:L/AT:P/PR:L/UI:N/VC:H/VI:H/VA:N/SC:N/SI:N/SA:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Better Auth: OAuth callback accepts mismatched state when cookie-backed state storage is used without PKCE

GHSA-wxw3-q3m9-c3jr

More information

Details

Am I affected?

Users are affected if all of the following are true:

  • The application uses better-auth at a version below 1.6.2 (or @better-auth/sso paired with such a version).
  • betterAuth({ account: { storeStateStrategy } }) is set to "cookie". The default "database" is not affected.
  • The application wires at least one OAuth provider through genericOAuth({ config }) with pkce: false, or it supplies a custom getToken or tokenUrl that does not require the stored codeVerifier. Stock social providers with PKCE on are not affected.
  • The provider returns arbitrary code values to the configured callback URL.

If users are on better-auth@1.6.2 or later, they are not affected.

Fix:

  1. Upgrade to better-auth@1.6.2 or later (current stable is 1.6.10).
  2. If users cannot upgrade, see workarounds below.
Summary

In parseGenericState, the cookie branch decrypted the oauth_state cookie and validated expiry, but did not compare the incoming OAuth state query parameter to the nonce that generateGenericState issued at sign-in. Any callback to /api/auth/oauth2/callback/<providerId> that arrived with a forged state and any code was therefore accepted as long as the browser still held a live oauth_state cookie. With pkce: false (or any getToken path that does not enforce a code-verifier round-trip), an attacker who forced the victim to deliver an attacker-controlled authorization code to the callback would mint a session bound to the attacker's external identity in the victim's browser. Account-linking flows behaved the same way, binding the attacker's external account to an authenticated victim row.

Details

The cookie branch of parseGenericState did not compare the cookie's stored nonce to the incoming state parameter. The database branch (the default) was not affected because the verification row is keyed by state and the lookup itself enforces equality.

The fix re-binds the cookie to the nonce: generateGenericState writes oauthState: state into the encrypted payload before storage, and parseGenericState rejects when parsedData.oauthState !== state. The same primitive covers every caller (generic-oauth, social, account-link, oauth-proxy passthrough, OIDC SSO, SAML relay state).

Patches

Fixed in better-auth@1.6.2 via PR #​8949 (commit 9deb7936a, merged 2026-04-09). The cookie branch of parseGenericState now rejects when the encrypted payload's nonce does not match the incoming state parameter; the database branch gained a defense-in-depth equality check.

Workarounds

If users cannot upgrade immediately:

  • Switch storeStateStrategy back to "database" (the default). This closes the cookie-only bypass without a code change.
  • Enable pkce: true on every affected genericOAuth provider. The codeVerifier is the missing primitive that the attacker cannot supply.
Impact
  • Forced-login (CSRF on OAuth callback): the attacker forces the victim's browser into an authenticated session bound to the attacker's external identity, allowing the attacker to observe the victim's actions inside the application.
  • Persistent account linking: account-link flows bind the attacker's external account to the victim's authenticated row, granting persistent access until the link is removed.
Credit

Reported by @​Jvr2022 via private advisory disclosure, and by @​alavesa (PatchPilots audit) via the public duplicate issue #​8897.

Resources

Severity

  • CVSS Score: 5.3 / 10 (Medium)
  • Vector String: CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:N/I:H/A:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Better Auth: Stale sessions persist after user deletion across admin, anonymous, and SCIM flows

GHSA-2vg6-77g8-24mp

More information

Details

Am I affected?

Users are affected if all of the following are true:

  • They configure secondaryStorage on betterAuth(...) (Redis, KV, or any external session cache).
  • session.storeSessionInDatabase is left unset or set to false (the default).
  • Their application's deployment uses one or more of:
    • The admin plugin and calls auth.api.removeUser(...) or authClient.admin.removeUser(...).
    • The anonymous plugin and exposes /delete-anonymous-user or relies on the after-link hook to clean up the anonymous user.
    • The @better-auth/scim plugin and exposes DELETE /scim/v2/Users/:userId.

If storeSessionInDatabase is true, sessions are also written to the database, and the database delete cascades; users are not affected.

Fix:

  1. Upgrade to better-auth@<patched-version> or later (and @better-auth/scim@<patched-version> if they use SCIM).
  2. If they cannot upgrade, see workarounds below.
Summary

When secondaryStorage is configured and storeSessionInDatabase is false, three user-deletion endpoints in better-auth plus one in @better-auth/scim call internalAdapter.deleteUser(userId) without first calling internalAdapter.deleteSessions(userId). The deleted user's session payload (which carries a cached user object) remains in secondary storage, and internalAdapter.findSession(token) keeps returning it as a valid session until the session TTL elapses (default 7 days).

Details

The vulnerable call sites are:

  • admin plugin's removeUser (packages/better-auth/src/plugins/admin/routes.ts:1463).
  • anonymous plugin's self-delete endpoint (packages/better-auth/src/plugins/anonymous/index.ts:222).
  • anonymous plugin's after-link hook (packages/better-auth/src/plugins/anonymous/index.ts:325).
  • @better-auth/scim's DELETE /scim/v2/Users/:userId (packages/scim/src/routes.ts:1019).

Working callers that already do the right thing: the core /delete-user self-delete and /delete-user/callback (packages/better-auth/src/api/routes/update-user.ts:551).

The fix shape extends each vulnerable caller to invoke deleteSessions(userId) before deleteUser(userId). The architectural follow-up centralizes the cleanup inside deleteUser itself or introduces a single deleteUserAndSessions orchestrator so future callers cannot regress this contract.

Patches

Fixed in better-auth@<patched-version> and @better-auth/scim@<patched-version>. All four user-deletion call sites now invoke deleteSessions(userId) before deleteUser(userId) so sessions are evicted from secondary storage at the same time the user row is removed.

Workarounds

If users cannot upgrade immediately:

  • Configuration-level: set session.storeSessionInDatabase: true. Subsequent user-delete writes reach the session table and the database cascade removes rows. Increases write volume for high-throughput sessions but eliminates the gap.
  • Code-level (admin path): when calling auth.api.removeUser, also call auth.api.revokeUserSessions({ body: { userId } }), which uses deleteSessions internally.
  • Code-level (SCIM path): wrap their SCIM provider's deprovisioning hook to call auth.api.revokeUserSessions(...) after the SCIM DELETE.
  • Code-level (anonymous path): in onLinkAccount, explicitly call internalAdapter.deleteSessions(anonymousUser.user.id) before allowing the new session to be issued.
Impact
  • Stale session validity: a deleted user's existing session cookie continues to authenticate against getSessionFromCtx until the session TTL elapses (default 7 days). Within that window, the deleted user retains their pre-existing read and write surface.
  • SCIM-driven deprovisioning gap: organizations using SCIM to revoke offboarded employees' access do not, in fact, revoke active sessions. The deleted account remains usable for up to 7 days after deprovisioning.
Credit

Reported by @​iruizsalinas.

Resources

Severity

  • CVSS Score: 3.8 / 10 (Low)
  • Vector String: CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:U/C:L/I:L/A:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


@​better-auth/oauth-provider's OAuth authorization-code grant allows concurrent redemption when two token requests race the find-then-delete primitive

CVE-2026-53518 / GHSA-7w99-5wm4-3g79

More information

Details

Am I affected?

Users are affected if all of the following are true:

  • Their project depends on @better-auth/oauth-provider at a version >= 1.6.0, < 1.6.11, or uses the embedded plugin in better-auth >= 1.4.8-beta.7, < 1.6.0, or enables the legacy oidc-provider or mcp plugins from better-auth/plugins.
  • Their application exposes /api/auth/oauth2/token (or the legacy plugins' /oauth2/token and /mcp/token) as a token endpoint to OAuth/OIDC clients, including internal MCP clients (Claude Desktop, custom MCP tool callers, AI agents).
  • Their application has not implemented an external mitigation: a load-balancer-level idempotency cache keyed by code, a database trigger that rejects duplicate token issuance for the same authorization code, or a custom adapter override that performs an atomic compare-and-delete.

Fix:

  1. Upgrade to @better-auth/oauth-provider@1.6.11 or later. If developers use the legacy plugin paths from better-auth/plugins, upgrade better-auth to 1.6.11 or later.
  2. If developers cannot upgrade, see workarounds below.
Summary

The OAuth provider's POST /oauth2/token endpoint, on the authorization_code grant, redeems a single-use authorization code through a non-atomic find-then-delete sequence. Two concurrent requests with the same code value both pass the read step before either delete completes, then both proceed to PKCE verification and createUserTokens. Each surviving request mints a fresh access token, refresh token, and id token. RFC 6749 §4.1.2 requires authorization codes to be single-use; this primitive does not enforce that under concurrency.

Details

The same architectural primitive (find a single-use verification row, then delete it, then trust the row to authorize) is used in 20 other call sites across the codebase. The deletion primitive returns Promise<void>, discarding the row count surfaced by adapter.deleteMany, so no call site can detect "another caller already claimed this row". The fix lands at the primitive layer rather than at any individual call site.

The fix introduces a claimVerificationByIdentifier primitive at the internal-adapter layer that performs an atomic claim-and-return, replaces the find-then-delete pair at this call site, and migrates the highest-impact variant sites in the same release.

Patches

Fixed in @better-auth/oauth-provider@1.6.11 and better-auth@1.6.11 for the legacy oidc-provider and mcp plugin paths. All three token-exchange call sites now consume the verification row through internalAdapter.consumeVerificationValue, an atomic claim primitive that deletes the row and returns its prior value in one operation. The first request to arrive takes the row and mints tokens; concurrent racers observe an empty result and return invalid_grant.

Error-code consistency is also tightened on the @better-auth/oauth-provider token endpoint: the malformed-verification-value branches previously returned a project-specific invalid_verification code, which is not part of RFC 6749 §5.2's response error set. Both branches now return invalid_grant so spec-compliant clients can branch on the standard code without a special case.

Workarounds

None of these close the bug fully without a code patch. Upgrading is the only good path.

  • Network-layer: deploy an authorization-server-aware reverse proxy (Envoy, NGINX with Lua, custom Cloudflare Worker) that holds an in-flight registry keyed by the code parameter and serializes concurrent requests for the same code. Fragile under multi-instance deployments unless the registry is shared (Redis-backed).
  • Database-layer: add a SQL or Mongo uniqueness constraint that prevents two oauthAccessToken rows from being created with the same upstream code reference. Adapter-specific and not always feasible since the schema does not currently store the source code.
  • Application-layer: wrap deleteVerificationByIdentifier with a custom hook that uses adapter.deleteMany and surfaces the count, then injects an invalid_grant rejection when the count is zero. Requires forking the internal adapter.
Impact
  • Multiple independent token sets from a single authorization: forked access tokens, refresh tokens, and id tokens issued from the same code, all valid for the original user's authorization scope.
  • Detection bypass: standard OAuth single-use enforcement does not fire for the second redemption when both requests interleave through the read step.
  • Legacy-plugin reach: oidc-provider and mcp plugins share the primitive on the same surface, so deployments using them inherit the same impact.
Credit

Reported by @​chdanielmueller.

Resources

Severity

  • CVSS Score: 7.6 / 10 (High)
  • Vector String: CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:P/VC:H/VI:H/VA:N/SC:N/SI:N/SA:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Better Auth has insecure cryptographic defaults in oidcProvider: alg=none advertised and plain PKCE accepted by default

GHSA-9h47-pqcx-hjr4

More information

Details

Am I affected?

Users are affected if all of the following are true:

  • Their application uses better-auth at a version below the patched release.
  • Their application enables oidcProvider() from better-auth/plugins/oidc-provider or mcp() from better-auth/plugins/mcp (the mcp plugin delegates to oidcProvider and inherits both defaults).
  • For the algorithm-negotiation impact: relying parties of the application's OIDC server use a JWT verification library that performs algorithm negotiation from the discovery document without pinning to a specific signing algorithm.
  • For the PKCE impact: the authorization URL is exposed to any party other than the user agent and the application's OP.

If the application only uses @better-auth/oauth-provider (the canonical replacement) and have not enabled the legacy plugins, it is not affected. The new package's discovery document excludes none and its authorize schema rejects plain at parse time.

Fix:

  1. Upgrade to better-auth@1.6.11 or later.
  2. Migrate from the deprecated oidcProvider and mcp plugins to @better-auth/oauth-provider when feasible.
  3. If developers cannot upgrade their applications, see workarounds below.
Summary

The legacy oidcProvider and mcp plugins exhibit two related defects in their OIDC discovery and authorize surfaces.

The discovery document advertises "none" in id_token_signing_alg_values_supported (and, for mcp, in resource_signing_alg_values_supported on the OAuth protected-resource metadata). Any relying party that performs algorithm negotiation from this metadata without pinning to a real signing algorithm may accept unsigned tokens.

PKCE plain is enabled by default. The runtime gate in the authorize handler accepts code_challenge_method=plain under this default, and a missing code_challenge_method parameter is silently downgraded to "plain" before the allowlist check. Discovery advertises code_challenge_methods_supported: ["S256"], contradicting the runtime acceptance of plain. RFC 9700 §2.1.1 (OAuth 2.1) explicitly forbids plain.

Details

The metadata builders unconditionally inject "none" into the alg list. The runtime authorize gate is structured so a buggy client that strips the code_challenge_method parameter still enters the plain code path because the handler rewrites the missing value to "plain" before the allowlist check fires.

@better-auth/oauth-provider (the deprecation target for oidcProvider) is not affected by either defect. The metadata builder uses a JWSAlgorithms type union that structurally excludes "none". The authorize schema is code_challenge_method: z.literal("S256").optional(), which rejects plain at parse time.

Patches

Fixed in better-auth@1.6.11. The legacy oidcProvider and mcp plugins now:

  • Drop "none" from id_token_signing_alg_values_supported (both plugins) and from resource_signing_alg_values_supported (mcp). Discovery no longer advertises the unsigned-token option.
  • Default allowPlainCodeChallengeMethod to false. A request that explicitly passes code_challenge_method=plain is rejected with invalid_request unless the integrator opts in.
  • Reject a code_challenge without an accompanying code_challenge_method instead of silently rewriting the missing value to plain. Clients that send code_challenge must also send code_challenge_method=S256.

Discovery and runtime behavior align on S256 only by default.

Integrators who must keep plain PKCE for legacy clients can restore the previous shape with oidcProvider({ allowPlainCodeChallengeMethod: true }) (and likewise for mcp). With the opt-in set, a request that omits code_challenge_method is treated as plain again, preserving backwards compatibility while keeping the secure default for everyone else. Both legacy plugins are deprecated long-term; the recommended migration is @better-auth/oauth-provider, which never advertised none or accepted plain PKCE.

Workarounds

If developers cannot upgrade their applications immediately:

  • Disable plain PKCE explicitly: set oidcProvider({ allowPlainCodeChallengeMethod: false }) (and the equivalent on mcp). Closes the runtime acceptance of plain even though the silent downgrade still rewrites missing methods.
  • Override the metadata to drop "none" from id_token_signing_alg_values_supported. For oidcProvider, pass metadata: { id_token_signing_alg_values_supported: ["RS256"] }. For mcp, set the same on options.oidcConfig.metadata. Verify by curling the .well-known endpoint.
  • Migrate to @better-auth/oauth-provider: the package is the deprecation target and is unaffected by both defects.
Impact
  • Algorithm-negotiation downgrade: relying parties that read the discovery document without pinning may accept unsigned (alg: "none") tokens.
  • Authorization-code interception: PKCE plain does not protect the authorization code if the URL leaks (Referer headers, browser history, screen capture, proxy logs). PKCE S256 is what protects against that exposure; with plain the protection is absent.
  • OAuth 2.1 / RFC 9700 non-conformance: deployments shipping the defaults are non-compliant with current standards.
Credit

Reported by @​subhanUmer.

Resources

Severity

  • CVSS Score: 8.7 / 10 (High)
  • Vector String: CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:C/C:H/I:H/A:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Better Auth vulnerable to unauthorized invitation acceptance via unverified email match in organization plugin

CVE-2026-53514 / GHSA-fmh4-wcc4-5jm3

More information

Details

Am I affected?

Users are affected if all of the following are true:

  • Their application uses better-auth with the organization plugin (import { organization } from "better-auth/plugins/organization").
  • Their application enables a sign-up surface that allows arbitrary unverified email registration. Most commonly emailAndPassword: { enabled: true } without requireEmailVerification: true.
  • Their application has not set requireEmailVerificationOnInvitation: true on the organization() options.
  • Their application invitation distribution flow allows anyone other than the invited mailbox owner to obtain the invitationId. Examples: admin UI surfacing the link, copy-paste into chat, forwarded email, mail-forwarding rules at the recipient's domain, link previews logging the URL, or a custom sendInvitationEmail integration that sends to a non-owner channel.

If their application set emailAndPassword: { enabled: true, requireEmailVerification: true } so unverified rows cannot reach a usable session, they are not affected. Setting requireEmailVerificationOnInvitation: true closes acceptInvitation and rejectInvitation, but getInvitation and listUserInvitations remain ungated even with that flag.

Fix:

  1. Upgrade to better-auth@1.6.11 or later.
  2. If developers cannot upgrade their application, see workarounds below.
Summary

The organization plugin's acceptInvitation endpoint trusts an email-string equality check as proof that the session user owns the invited address. With Better Auth's stock emailAndPassword: { enabled: true } configuration, requireEmailVerification defaults to false, so an attacker can sign up a row keyed to victim@target.example (auto-signed-in, emailVerified: false) before the legitimate owner. When an organization admin invites that address, the attacker presents the invitationId and accepts the invitation, joining the organization at the invited role.

Details

The recipient gate compares invitation.email.toLowerCase() to session.user.email.toLowerCase() and returns 403 on mismatch. The opt-in requireEmailVerificationOnInvitation flag adds an emailVerified check, but it defaults to false and only fires on acceptInvitation and rejectInvitation; getInvitation and listUserInvitations have no emailVerified gate at all.

The bearer token (invitationId) is by default 32 chars over [a-zA-Z0-9] (~190 bits), so the realistic attack vector is leakage of the invitation link rather than brute force.

The fix shape defaults the emailVerified gate to on and extends it across all four invitation endpoints (acceptInvitation, rejectInvitation, getInvitation, listUserInvitations). This is the same trust-primitive class as GHSA-g38m-r43w-p2q7 (OAuth auto-link); both ship the rule "email equality is not ownership proof; both sides must prove ownership".

Patches

Fixed in better-auth@1.6.11. All four invitation recipient endpoints (acceptInvitation, rejectInvitation, getInvitation, listUserInvitations) now require the session user's emailVerified to be true in addition to the email-string match. The requireEmailVerificationOnInvitation option default flips from false to true, so applications are secure out of the box.

getInvitation and listUserInvitations use the new EMAIL_VERIFICATION_REQUIRED_FOR_INVITATION error code so the wording matches the operation; acceptInvitation and rejectInvitation keep the existing EMAIL_VERIFICATION_REQUIRED_BEFORE_ACCEPTING_OR_REJECTING_INVITATION code. Server-side calls to listUserInvitations that pass ctx.query.email without an authenticated session continue to bypass the gate; the gate is specific to session-authenticated recipient calls.

Integrators who intentionally accept invitations on unverified sessions can preserve the legacy permissive behavior with organization({ requireEmailVerificationOnInvitation: false }). The option is marked @deprecated; the gate at each call site carries a FIXME pointing at the next-minor follow-up that drops the option and makes the check unconditional. Operators that take this opt-out should understand the takeover risk before doing so.

Workarounds

If developers cannot upgrade their applications immediately:

  • Set organization({ requireEmailVerificationOnInvitation: true }). Closes acceptInvitation and rejectInvitation against unverified sessions. Does not close getInvitation or listUserInvitations.
  • Set emailAndPassword.requireEmailVerification: true (or remove email/password sign-up entirely). Closes the pre-registration step itself.
  • Layer middleware on the organization invitation routes that asserts session.user.emailVerified === true and rejects otherwise.
Impact
  • Account takeover via pre-account hijacking on the org invitation surface: the attacker, holding only an unverified self-issued session and the leaked invitationId, joins the organization as a member at the invited role.
  • Organization membership reach: the attacker reads invitation contents and any organization-scoped data the joined role can see, and acts as a member of the victim organization.
Credit

Reported by @​widavies.

Resources

Severity

  • CVSS Score: 7.7 / 10 (High)
  • Vector String: CVSS:3.1/AV:N/AC:H/PR:L/UI:R/S:C/C:H/I:H/A:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Better Auth has an account takeover issue via OAuth auto-link to unverified pre-registered email

CVE-2026-53516 / GHSA-g38m-r43w-p2q7

More information

Details

Am I affected?

Users are affected if all of the following are true:

  • Their application uses better-auth at a version < 1.6.11 on the stable line, or any current next pre-release.
  • emailAndPassword.enabled: true is set in their application's betterAuth({ ... }) configuration.
  • At least one OAuth or SSO provider is configured (any built-in social provider, or genericOAuth(...), or any provider via @better-auth/sso).
  • account.accountLinking.disableImplicitLinking is not set to true.
  • account.accountLinking.enabled is not set to false.

Setting either disableImplicitLinking: true or enabled: false closes the hole at the cost of breaking the standard "add another login method" UX. emailAndPassword.requireEmailVerification: true does not mitigate, because the link-time emailVerified flip promotes the attacker's row to verified, after which the password login becomes usable.

Fix:

  1. Upgrade to better-auth@1.6.11 or later.
  2. If developers cannot upgrade, see workarounds below.
Summary

The OAuth callback's auto-link gate in handleOAuthUserInfo admits an implicit account link whenever the provider asserts email_verified: true, without requiring the local user row's emailVerified to also be true. An attacker who pre-registers a victim's email through /sign-up/email (which writes a row with emailVerified: false) can have the victim's later OAuth identity bound to the attacker's user row, granting both a password login and the victim's OAuth identity on the same account. This is the pre-account-hijacking class — the same shape as Microsoft "nOAuth" (2023) and the Sign in with Apple JWT flaw (2020).

Details

The auto-link gate validates only the OAuth provider's userInfo.emailVerified claim. The local row's emailVerified field is never read. When no (accountId, providerId) match exists, the user lookup falls back to email, which surfaces any pre-registered row at that email.

A separate post-link step promotes the local emailVerified to true when the provider's claim is true and the local email matches the provider's email. This step is correct for legitimate first-time linking, but combined with the missing local-side check it becomes load-bearing for the takeover: after the link, the attacker's password row is treated as verified, defeating requireEmailVerification: true as a mitigation.

The fix adds the local-side ownership check to the gate: implicit linking now also rejects when dbUser.user.emailVerified is false. The same primitive lives in one-tap and inherits the same fix shape; the SSO domainVerified short-circuit follows separately as a hardening change.

Patches

Fixed in better-auth@1.6.11. Implicit linking now refuses to attach an OAuth identity to a local account whose emailVerified flag is false. The same gate change applies in the one-tap sign-in plugin, which previously had its own simpler linking path. The Google ID-token email_verified claim is also normalized through toBoolean so a string "false" is treated as falsy (some Google responses send the string, which the prior code treated as truthy).

The public surface for the new gate is account.accountLinking.requireLocalEmailVerified, defaulted to true. Applications whose users sign up through OAuth without ever verifying their email locally can opt out with account: { accountLinking: { requireLocalEmailVerified: false } } to retain the legacy permissive behavior. The option is marked @deprecated; the gate at each call site carries a FIXME pointing at the next-minor follow-up that drops the option and makes the check unconditional.

Test fixtures across the admin, oidc-provider, mcp, generic-oauth, last-login-method, and oauth-provider suites now pre-verify created users via a databaseHooks.user.create.before hook (or the disableTestUser opt-in on the oauth-provider RP fixture) so those suites continue to exercise their role and flow logic rather than tripping the new gate.

Workarounds

If developers cannot upgrade their applications immediately:

  • Disable implicit linking: set account.accountLinking.disableImplicitLinking: true. Forces all linking through the authenticated /link-social endpoint where the user must already be signed in.
  • Disable linking entirely: set account.accountLinking.enabled: false. Closes the hole but breaks the multi-login-method UX entirely.

emailAndPassword.requireEmailVerification: true alone does not mitigate, because the link-time emailVerified flip promotes the attacker's row to verified.

Impact
  • Account takeover via pre-account hijacking: the attacker holds a working password login plus the victim's OAuth identity on the same account, granting persistent access.
  • requireEmailVerification: true bypass: the attacker's password login becomes usable post-link.
  • Cross-flow reach: every OAuth and SSO sign-in path that calls handleOAuthUserInfo is affected (built-in social providers, generic-oauth, oauth-proxy, SSO OIDC, SSO SAML, one-tap).
Credit

Reported by @​avrmeduard.

Resources

Severity

  • CVSS Score: 8.3 / 10 (High)
  • Vector String: CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:L

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Better Auth: OAuth refresh-token replay via missing client authentication on oidc-provider and mcp plugins

CVE-2026-53512 / GHSA-pw9m-5jxm-xr6h

More information

Details

Am I affected?

Users are affected if all of the following are true:

  • Their application uses better-auth and has enabled at least one of: oidcProvider() (imported from better-auth/plugins/oidc-provider), or mcp() (imported from better-auth/plugins/mcp).
  • Their application has at least one confidential OAuth client registered (any client with type: "web" | "native" | "user-agent-based" in the oauthApplication table, or any trustedClients entry without type: "public"). Public clients with PKCE are not affected.
  • Their application uses better-auth at a version below the patched release.

If an application only uses @better-auth/oauth-provider (the canonical replacement for oidc-provider) and the mcp plugin is not enabled, it is not affected.

Fix:

  1. Upgrade to better-auth@1.6.11 or later.
  2. Migrate from the deprecated oidcProvider() to @better-auth/oauth-provider when feasible. The new package enforces client authentication on both grants by default.
  3. If developers cannot upgrade their applications, see workarounds below.
Summary

The legacy oidcProvider and mcp plugins each expose an OAuth 2.0 token endpoint whose refresh_token grant authenticates the request entirely on possession of the bound refreshToken row and a matching client_id. Neither plugin verifies the registered confidential client's client_secret on the refresh path. An attacker who obtains any valid refresh_token (via database read, log capture, browser-side XSS, or CORS-amplified script in the mcp case) and the public client_id can mint fresh access tokens and rotated refresh tokens until the chain is revoked.

Details

RFC 6749 §6 and OAuth 2.1 §4.3 require confidential clients to authenticate to the token endpoint on every grant, including refresh. The same plugins' authorization_code grant correctly enforces client_secret (the oidc-provider via verifyStoredClientSecret, the mcp plugin via raw equality), which proves the omission on the refresh path is a regression rather than a design choice.

Token rotation issues a new refresh_token with each call, so a single leaked refresh-token grants indefinite access until the row is revoked or its refreshTokenExpiresAt (default 7 days) passes; rotation refreshes that window each call.

Two adjacent issues on the mcp surface ship in the same patch. The mcp authorization_code grant uses raw === for client-secret comparison and ignores the storeClientSecret: "encrypted" | "hashed" configuration; the fix routes both grants through verifyStoredClientSecret. The mcp /mcp/token endpoint sets Access-Control-Allow-Origin: * unconditionally, which amplifies the refresh bypass in browser contexts; the fix narrows the CORS allowlist.

The newer @better-auth/oauth-provider package routes both grants through validateClientCredentials and is not affected.

Patches

Fixed in better-auth@1.6.11. The legacy oidcProvider and mcp token endpoints now require client_secret on the refresh_token grant for confidential clients, using the same constant-time comparison the authorization_code grant already used. Public clients are unaffected (they have no secret to enforce, and PKCE substitutes on the auth-code grant).

The Authorization: Basic parser is fixed to follow RFC 6749 §2.3.1: the credential is split on the first colon and each half is percent-decoded. Client IDs and secrets that contain reserved characters now authenticate correctly. The /mcp/token endpoint's CORS configuration is narrowed in the same change (the wildcard Access-Control-Allow-Origin: * header is removed), matching the standalone @better-auth/oauth-provider package.

The deprecated oidc-provider plugin remains deprecated. The recommended migration path is @better-auth/oauth-provider.

Workarounds

None of these close the bug fully without a code patch.

  • Migrate to @better-auth/oauth-provider if your deployment can adopt the new plugin. It enforces client_secret on both grants.
  • Force all clients to public + PKCE: set every client's type: "public" and require PKCE. The bug is unreachable when there is no client_secret to verify.
  • Network-layer ingress restriction: limit /api/auth/oauth2/token and /api/auth/mcp/token to known client IPs at the load balancer. Practical for server-to-server flows, not for end-user-device clients.
  • Out-of-band refresh-token rotation: on any suspicion of leak, run db.deleteMany({ model: "oauthAccessToken", where: [{ field: "clientId", value: <id> }] }) to invalidate all refresh tokens for the affected client.
  • For the mcp endpoint specifically: drop the wildcard CORS at an upstream proxy and replace with a tight allowlist.
Impact
  • Indefinite confidential-client impersonation: an attacker holding any valid refresh_token and the public client_id can mint access tokens and rotated refresh tokens indefinitely, until the row is revoked. Rotation refreshes the expiration window each call.
  • Resource access at the user's authorized scope: every minted access token

Note

PR body was truncated to here.

@renovate renovate Bot added dependencies Pull requests that update a dependency file security labels May 15, 2026
@renovate
renovate Bot force-pushed the renovate/npm-better-auth-vulnerability branch from 9774ddf to a7c1851 Compare May 18, 2026 19:33
@renovate
renovate Bot force-pushed the renovate/npm-better-auth-vulnerability branch 2 times, most recently from 93c1f9c to 8a2444b Compare June 1, 2026 17:45
@renovate
renovate Bot force-pushed the renovate/npm-better-auth-vulnerability branch from 8a2444b to 62a9326 Compare June 11, 2026 10:57
@renovate
renovate Bot force-pushed the renovate/npm-better-auth-vulnerability branch from 62a9326 to 46c3443 Compare July 7, 2026 21:57
@renovate renovate Bot changed the title chore(deps): update dependency better-auth to v1.6.2 [security] chore(deps): update dependency better-auth to v1.6.11 [security] Jul 7, 2026
@renovate
renovate Bot force-pushed the renovate/npm-better-auth-vulnerability branch from 46c3443 to 05f0973 Compare July 8, 2026 01:13
@renovate renovate Bot changed the title chore(deps): update dependency better-auth to v1.6.11 [security] chore(deps): update dependency better-auth to v1.6.13 [security] Jul 8, 2026
@renovate
renovate Bot force-pushed the renovate/npm-better-auth-vulnerability branch from 05f0973 to e22a096 Compare July 12, 2026 12:14
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file security

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants