chore(deps): update dependency better-auth to v1.6.13 [security] - #75
Open
renovate[bot] wants to merge 1 commit into
Open
chore(deps): update dependency better-auth to v1.6.13 [security]#75renovate[bot] wants to merge 1 commit into
renovate[bot] wants to merge 1 commit into
Conversation
renovate
Bot
force-pushed
the
renovate/npm-better-auth-vulnerability
branch
from
May 18, 2026 19:33
9774ddf to
a7c1851
Compare
renovate
Bot
force-pushed
the
renovate/npm-better-auth-vulnerability
branch
2 times, most recently
from
June 1, 2026 17:45
93c1f9c to
8a2444b
Compare
renovate
Bot
force-pushed
the
renovate/npm-better-auth-vulnerability
branch
from
June 11, 2026 10:57
8a2444b to
62a9326
Compare
renovate
Bot
force-pushed
the
renovate/npm-better-auth-vulnerability
branch
from
July 7, 2026 21:57
62a9326 to
46c3443
Compare
renovate
Bot
force-pushed
the
renovate/npm-better-auth-vulnerability
branch
from
July 8, 2026 01:13
46c3443 to
05f0973
Compare
renovate
Bot
force-pushed
the
renovate/npm-better-auth-vulnerability
branch
from
July 12, 2026 12:14
05f0973 to
e22a096
Compare
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
renovate
Bot
force-pushed
the
renovate/npm-better-auth-vulnerability
branch
from
July 16, 2026 17:43
e22a096 to
0e86160
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This PR contains the following updates:
1.5.3→1.6.13Better Auth: OAuth callback accepts mismatched
statewhen cookie-backed state storage is used without PKCEGHSA-wxw3-q3m9-c3jr
More information
Details
Am I affected?
Users are affected if all of the following are true:
better-authat a version below1.6.2(or@better-auth/ssopaired with such a version).betterAuth({ account: { storeStateStrategy } })is set to"cookie". The default"database"is not affected.genericOAuth({ config })withpkce: false, or it supplies a customgetTokenortokenUrlthat does not require the storedcodeVerifier. Stock social providers with PKCE on are not affected.codevalues to the configured callback URL.If users are on
better-auth@1.6.2or later, they are not affected.Fix:
better-auth@1.6.2or later (current stable is1.6.10).Summary
In
parseGenericState, the cookie branch decrypted theoauth_statecookie and validated expiry, but did not compare the incoming OAuthstatequery parameter to the nonce thatgenerateGenericStateissued at sign-in. Any callback to/api/auth/oauth2/callback/<providerId>that arrived with a forgedstateand anycodewas therefore accepted as long as the browser still held a liveoauth_statecookie. Withpkce: false(or anygetTokenpath 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
parseGenericStatedid not compare the cookie's stored nonce to the incomingstateparameter. The database branch (the default) was not affected because the verification row is keyed bystateand the lookup itself enforces equality.The fix re-binds the cookie to the nonce:
generateGenericStatewritesoauthState: stateinto the encrypted payload before storage, andparseGenericStaterejects whenparsedData.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.2via PR #8949 (commit9deb7936a, merged 2026-04-09). The cookie branch ofparseGenericStatenow rejects when the encrypted payload's nonce does not match the incomingstateparameter; the database branch gained a defense-in-depth equality check.Workarounds
If users cannot upgrade immediately:
storeStateStrategyback to"database"(the default). This closes the cookie-only bypass without a code change.pkce: trueon every affectedgenericOAuthprovider. ThecodeVerifieris the missing primitive that the attacker cannot supply.Impact
Credit
Reported by @Jvr2022 via private advisory disclosure, and by @alavesa (PatchPilots audit) via the public duplicate issue #8897.
Resources
Severity
CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:N/I:H/A:NReferences
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:
@better-auth/oauth-providerat a version>= 1.6.0, < 1.6.11, or uses the embedded plugin inbetter-auth >= 1.4.8-beta.7, < 1.6.0.offline_accessscope, so refresh tokens are minted.If developer applications do not request
offline_accessfor any client, no refresh tokens are minted and they are not exposed.Fix:
@better-auth/oauth-provider@1.6.11or later.Summary
The OAuth provider's
POST /oauth2/tokenendpoint, on therefresh_tokengrant, performs a non-atomic read / validate / revoke / mint sequence on theoauthRefreshTokenrow. 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 whenrevokedis 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.updatepredicate on the parent row is keyed onidonly; it does not includerevoked IS NULL, so two concurrent updates both succeed (last-write-wins, no error path). The schema does not declareuniqueonoauthRefreshToken.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
revokedcheck, 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 itsrefreshTokenExpiresAt(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 NULLwith a rowcount check), so the losing rotation fails closed withinvalid_grantand 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 onoauthRefreshToken.tokenfor parity withoauthAccessToken.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 explicitrevokeRefreshTokenpath uses the same CAS. On a contested rotation, exactly one caller wins and mints a fresh refresh token; the loser receivesinvalid_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.11ships a compatibility fix in the same wave: the in-memorywhereclause now treatsundefinedandnullas equivalent under aneq nullpredicate, mirroring SQLIS NULLand Mongo's missing-or-null semantics. Without this change, the CAS predicateWHERE revoked IS NULLfalls through on every call against a row whose optionalrevokedfield is absent (the adapter factory'stransformInputskips writingundefinedwhen 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
nextchannel. 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 aFIXME(strict-family-invalidation)marker.Schema-migration note: the better-auth migration generator only emits
UNIQUEfor newly-created columns. Existing installs will not pick up the newoauthRefreshToken.tokenunique constraint frommigrate/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 customgenerateRefreshTokencallback fail loudly.Workarounds
None of these close the bug fully without a code patch.
adapter.updateonoauthRefreshTokenwith a row-level pessimistic lock (SELECT ... FOR UPDATE). Narrows the window without closing it.oauthProvider({ refreshTokenExpiresIn: 60 })to expire forked families within one minute. Trades attacker persistence for shorter user sessions.offline_accessscope. Closes the surface but breaks long-lived sessions.Impact
Credit
Reported by @chdanielmueller.
Resources
Severity
CVSS:4.0/AV:N/AC:L/AT:P/PR:L/UI:N/VC:H/VI:H/VA:N/SC:N/SI:N/SA:NReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
Better Auth: OAuth callback accepts mismatched
statewhen cookie-backed state storage is used without PKCEGHSA-wxw3-q3m9-c3jr
More information
Details
Am I affected?
Users are affected if all of the following are true:
better-authat a version below1.6.2(or@better-auth/ssopaired with such a version).betterAuth({ account: { storeStateStrategy } })is set to"cookie". The default"database"is not affected.genericOAuth({ config })withpkce: false, or it supplies a customgetTokenortokenUrlthat does not require the storedcodeVerifier. Stock social providers with PKCE on are not affected.codevalues to the configured callback URL.If users are on
better-auth@1.6.2or later, they are not affected.Fix:
better-auth@1.6.2or later (current stable is1.6.10).Summary
In
parseGenericState, the cookie branch decrypted theoauth_statecookie and validated expiry, but did not compare the incoming OAuthstatequery parameter to the nonce thatgenerateGenericStateissued at sign-in. Any callback to/api/auth/oauth2/callback/<providerId>that arrived with a forgedstateand anycodewas therefore accepted as long as the browser still held a liveoauth_statecookie. Withpkce: false(or anygetTokenpath 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
parseGenericStatedid not compare the cookie's stored nonce to the incomingstateparameter. The database branch (the default) was not affected because the verification row is keyed bystateand the lookup itself enforces equality.The fix re-binds the cookie to the nonce:
generateGenericStatewritesoauthState: stateinto the encrypted payload before storage, andparseGenericStaterejects whenparsedData.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.2via PR #8949 (commit9deb7936a, merged 2026-04-09). The cookie branch ofparseGenericStatenow rejects when the encrypted payload's nonce does not match the incomingstateparameter; the database branch gained a defense-in-depth equality check.Workarounds
If users cannot upgrade immediately:
storeStateStrategyback to"database"(the default). This closes the cookie-only bypass without a code change.pkce: trueon every affectedgenericOAuthprovider. ThecodeVerifieris the missing primitive that the attacker cannot supply.Impact
Credit
Reported by @Jvr2022 via private advisory disclosure, and by @alavesa (PatchPilots audit) via the public duplicate issue #8897.
Resources
Severity
CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:N/I:H/A:NReferences
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:
secondaryStorageonbetterAuth(...)(Redis, KV, or any external session cache).session.storeSessionInDatabaseis left unset or set tofalse(the default).adminplugin and callsauth.api.removeUser(...)orauthClient.admin.removeUser(...).anonymousplugin and exposes/delete-anonymous-useror relies on the after-link hook to clean up the anonymous user.@better-auth/scimplugin and exposesDELETE /scim/v2/Users/:userId.If
storeSessionInDatabaseistrue, sessions are also written to the database, and the database delete cascades; users are not affected.Fix:
better-auth@<patched-version>or later (and@better-auth/scim@<patched-version>if they use SCIM).Summary
When
secondaryStorageis configured andstoreSessionInDatabaseisfalse, three user-deletion endpoints inbetter-authplus one in@better-auth/scimcallinternalAdapter.deleteUser(userId)without first callinginternalAdapter.deleteSessions(userId). The deleted user's session payload (which carries a cached user object) remains in secondary storage, andinternalAdapter.findSession(token)keeps returning it as a valid session until the session TTL elapses (default 7 days).Details
The vulnerable call sites are:
adminplugin'sremoveUser(packages/better-auth/src/plugins/admin/routes.ts:1463).anonymousplugin's self-delete endpoint (packages/better-auth/src/plugins/anonymous/index.ts:222).anonymousplugin's after-link hook (packages/better-auth/src/plugins/anonymous/index.ts:325).@better-auth/scim'sDELETE /scim/v2/Users/:userId(packages/scim/src/routes.ts:1019).Working callers that already do the right thing: the core
/delete-userself-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)beforedeleteUser(userId). The architectural follow-up centralizes the cleanup insidedeleteUseritself or introduces a singledeleteUserAndSessionsorchestrator 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 invokedeleteSessions(userId)beforedeleteUser(userId)so sessions are evicted from secondary storage at the same time the user row is removed.Workarounds
If users cannot upgrade immediately:
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.auth.api.removeUser, also callauth.api.revokeUserSessions({ body: { userId } }), which usesdeleteSessionsinternally.auth.api.revokeUserSessions(...)after the SCIM DELETE.onLinkAccount, explicitly callinternalAdapter.deleteSessions(anonymousUser.user.id)before allowing the new session to be issued.Impact
getSessionFromCtxuntil the session TTL elapses (default 7 days). Within that window, the deleted user retains their pre-existing read and write surface.Credit
Reported by @iruizsalinas.
Resources
Severity
CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:U/C:L/I:L/A:NReferences
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:
@better-auth/oauth-providerat a version>= 1.6.0, < 1.6.11, or uses the embedded plugin inbetter-auth >= 1.4.8-beta.7, < 1.6.0, or enables the legacyoidc-providerormcpplugins frombetter-auth/plugins./api/auth/oauth2/token(or the legacy plugins'/oauth2/tokenand/mcp/token) as a token endpoint to OAuth/OIDC clients, including internal MCP clients (Claude Desktop, custom MCP tool callers, AI agents).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:
@better-auth/oauth-provider@1.6.11or later. If developers use the legacy plugin paths frombetter-auth/plugins, upgradebetter-authto1.6.11or later.Summary
The OAuth provider's
POST /oauth2/tokenendpoint, on theauthorization_codegrant, redeems a single-use authorization code through a non-atomic find-then-delete sequence. Two concurrent requests with the samecodevalue both pass the read step before either delete completes, then both proceed to PKCE verification andcreateUserTokens. 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 byadapter.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
claimVerificationByIdentifierprimitive 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.11andbetter-auth@1.6.11for the legacyoidc-providerandmcpplugin paths. All three token-exchange call sites now consume the verification row throughinternalAdapter.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 returninvalid_grant.Error-code consistency is also tightened on the
@better-auth/oauth-providertoken endpoint: the malformed-verification-value branches previously returned a project-specificinvalid_verificationcode, which is not part of RFC 6749 §5.2's response error set. Both branches now returninvalid_grantso 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.
codeparameter and serializes concurrent requests for the same code. Fragile under multi-instance deployments unless the registry is shared (Redis-backed).oauthAccessTokenrows 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.deleteVerificationByIdentifierwith a custom hook that usesadapter.deleteManyand surfaces the count, then injects aninvalid_grantrejection when the count is zero. Requires forking the internal adapter.Impact
oidc-providerandmcpplugins share the primitive on the same surface, so deployments using them inherit the same impact.Credit
Reported by @chdanielmueller.
Resources
Severity
CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:P/VC:H/VI:H/VA:N/SC:N/SI:N/SA:NReferences
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:
better-authat a version below the patched release.oidcProvider()frombetter-auth/plugins/oidc-providerormcp()frombetter-auth/plugins/mcp(the mcp plugin delegates tooidcProviderand inherits both defaults).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 excludesnoneand its authorize schema rejectsplainat parse time.Fix:
better-auth@1.6.11or later.oidcProviderandmcpplugins to@better-auth/oauth-providerwhen feasible.Summary
The legacy
oidcProviderandmcpplugins exhibit two related defects in their OIDC discovery and authorize surfaces.The discovery document advertises
"none"inid_token_signing_alg_values_supported(and, formcp, inresource_signing_alg_values_supportedon 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
plainis enabled by default. The runtime gate in the authorize handler acceptscode_challenge_method=plainunder this default, and a missingcode_challenge_methodparameter is silently downgraded to"plain"before the allowlist check. Discovery advertisescode_challenge_methods_supported: ["S256"], contradicting the runtime acceptance ofplain. RFC 9700 §2.1.1 (OAuth 2.1) explicitly forbidsplain.Details
The metadata builders unconditionally inject
"none"into the alg list. The runtime authorize gate is structured so a buggy client that strips thecode_challenge_methodparameter 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 foroidcProvider) is not affected by either defect. The metadata builder uses aJWSAlgorithmstype union that structurally excludes"none". The authorize schema iscode_challenge_method: z.literal("S256").optional(), which rejectsplainat parse time.Patches
Fixed in
better-auth@1.6.11. The legacyoidcProviderandmcpplugins now:"none"fromid_token_signing_alg_values_supported(both plugins) and fromresource_signing_alg_values_supported(mcp). Discovery no longer advertises the unsigned-token option.allowPlainCodeChallengeMethodtofalse. A request that explicitly passescode_challenge_method=plainis rejected withinvalid_requestunless the integrator opts in.code_challengewithout an accompanyingcode_challenge_methodinstead of silently rewriting the missing value toplain. Clients that sendcode_challengemust also sendcode_challenge_method=S256.Discovery and runtime behavior align on
S256only by default.Integrators who must keep plain PKCE for legacy clients can restore the previous shape with
oidcProvider({ allowPlainCodeChallengeMethod: true })(and likewise formcp). With the opt-in set, a request that omitscode_challenge_methodis treated asplainagain, 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 advertisednoneor accepted plain PKCE.Workarounds
If developers cannot upgrade their applications immediately:
oidcProvider({ allowPlainCodeChallengeMethod: false })(and the equivalent onmcp). Closes the runtime acceptance ofplaineven though the silent downgrade still rewrites missing methods."none"fromid_token_signing_alg_values_supported. ForoidcProvider, passmetadata: { id_token_signing_alg_values_supported: ["RS256"] }. Formcp, set the same onoptions.oidcConfig.metadata. Verify by curling the.well-knownendpoint.@better-auth/oauth-provider: the package is the deprecation target and is unaffected by both defects.Impact
alg: "none") tokens.plaindoes not protect the authorization code if the URL leaks (Referer headers, browser history, screen capture, proxy logs). PKCES256is what protects against that exposure; withplainthe protection is absent.Credit
Reported by @subhanUmer.
Resources
Severity
CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:C/C:H/I:H/A:NReferences
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:
better-authwith theorganizationplugin (import { organization } from "better-auth/plugins/organization").emailAndPassword: { enabled: true }withoutrequireEmailVerification: true.requireEmailVerificationOnInvitation: trueon theorganization()options.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 customsendInvitationEmailintegration 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. SettingrequireEmailVerificationOnInvitation: trueclosesacceptInvitationandrejectInvitation, butgetInvitationandlistUserInvitationsremain ungated even with that flag.Fix:
better-auth@1.6.11or later.Summary
The organization plugin's
acceptInvitationendpoint trusts an email-string equality check as proof that the session user owns the invited address. With Better Auth's stockemailAndPassword: { enabled: true }configuration,requireEmailVerificationdefaults tofalse, so an attacker can sign up a row keyed tovictim@target.example(auto-signed-in,emailVerified: false) before the legitimate owner. When an organization admin invites that address, the attacker presents theinvitationIdand accepts the invitation, joining the organization at the invited role.Details
The recipient gate compares
invitation.email.toLowerCase()tosession.user.email.toLowerCase()and returns 403 on mismatch. The opt-inrequireEmailVerificationOnInvitationflag adds anemailVerifiedcheck, but it defaults tofalseand only fires onacceptInvitationandrejectInvitation;getInvitationandlistUserInvitationshave noemailVerifiedgate 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
emailVerifiedgate 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'semailVerifiedto betruein addition to the email-string match. TherequireEmailVerificationOnInvitationoption default flips fromfalsetotrue, so applications are secure out of the box.getInvitationandlistUserInvitationsuse the newEMAIL_VERIFICATION_REQUIRED_FOR_INVITATIONerror code so the wording matches the operation;acceptInvitationandrejectInvitationkeep the existingEMAIL_VERIFICATION_REQUIRED_BEFORE_ACCEPTING_OR_REJECTING_INVITATIONcode. Server-side calls tolistUserInvitationsthat passctx.query.emailwithout 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 aFIXMEpointing 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:
organization({ requireEmailVerificationOnInvitation: true }). ClosesacceptInvitationandrejectInvitationagainst unverified sessions. Does not closegetInvitationorlistUserInvitations.emailAndPassword.requireEmailVerification: true(or remove email/password sign-up entirely). Closes the pre-registration step itself.session.user.emailVerified === trueand rejects otherwise.Impact
invitationId, joins the organization as a member at the invited role.Credit
Reported by @widavies.
Resources
Severity
CVSS:3.1/AV:N/AC:H/PR:L/UI:R/S:C/C:H/I:H/A:NReferences
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:
better-authat a version< 1.6.11on the stable line, or any currentnextpre-release.emailAndPassword.enabled: trueis set in their application'sbetterAuth({ ... })configuration.genericOAuth(...), or any provider via@better-auth/sso).account.accountLinking.disableImplicitLinkingis not set totrue.account.accountLinking.enabledis not set tofalse.Setting either
disableImplicitLinking: trueorenabled: falsecloses the hole at the cost of breaking the standard "add another login method" UX.emailAndPassword.requireEmailVerification: truedoes not mitigate, because the link-timeemailVerifiedflip promotes the attacker's row to verified, after which the password login becomes usable.Fix:
better-auth@1.6.11or later.Summary
The OAuth callback's auto-link gate in
handleOAuthUserInfoadmits an implicit account link whenever the provider assertsemail_verified: true, without requiring the local user row'semailVerifiedto also betrue. An attacker who pre-registers a victim's email through/sign-up/email(which writes a row withemailVerified: 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.emailVerifiedclaim. The local row'semailVerifiedfield 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
emailVerifiedtotruewhen the provider's claim istrueand 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, defeatingrequireEmailVerification: trueas a mitigation.The fix adds the local-side ownership check to the gate: implicit linking now also rejects when
dbUser.user.emailVerifiedisfalse. The same primitive lives inone-tapand inherits the same fix shape; the SSOdomainVerifiedshort-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 whoseemailVerifiedflag isfalse. The same gate change applies in theone-tapsign-in plugin, which previously had its own simpler linking path. The Google ID-tokenemail_verifiedclaim is also normalized throughtoBooleanso 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 totrue. Applications whose users sign up through OAuth without ever verifying their email locally can opt out withaccount: { accountLinking: { requireLocalEmailVerified: false } }to retain the legacy permissive behavior. The option is marked@deprecated; the gate at each call site carries aFIXMEpointing 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, andoauth-providersuites now pre-verify created users via adatabaseHooks.user.create.beforehook (or thedisableTestUseropt-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:
account.accountLinking.disableImplicitLinking: true. Forces all linking through the authenticated/link-socialendpoint where the user must already be signed in.account.accountLinking.enabled: false. Closes the hole but breaks the multi-login-method UX entirely.emailAndPassword.requireEmailVerification: truealone does not mitigate, because the link-timeemailVerifiedflip promotes the attacker's row to verified.Impact
requireEmailVerification: truebypass: the attacker's password login becomes usable post-link.handleOAuthUserInfois affected (built-in social providers, generic-oauth, oauth-proxy, SSO OIDC, SSO SAML, one-tap).Credit
Reported by @avrmeduard.
Resources
Severity
CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:LReferences
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:
better-authand has enabled at least one of:oidcProvider()(imported frombetter-auth/plugins/oidc-provider), ormcp()(imported frombetter-auth/plugins/mcp).type: "web" | "native" | "user-agent-based"in theoauthApplicationtable, or anytrustedClientsentry withouttype: "public"). Public clients with PKCE are not affected.better-authat a version below the patched release.If an application only uses
@better-auth/oauth-provider(the canonical replacement foroidc-provider) and themcpplugin is not enabled, it is not affected.Fix:
better-auth@1.6.11or later.oidcProvider()to@better-auth/oauth-providerwhen feasible. The new package enforces client authentication on both grants by default.Summary
The legacy
oidcProviderandmcpplugins each expose an OAuth 2.0 token endpoint whoserefresh_tokengrant authenticates the request entirely on possession of the boundrefreshTokenrow and a matchingclient_id. Neither plugin verifies the registered confidential client'sclient_secreton the refresh path. An attacker who obtains any validrefresh_token(via database read, log capture, browser-side XSS, or CORS-amplified script in the mcp case) and the publicclient_idcan 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_codegrant correctly enforcesclient_secret(the oidc-provider viaverifyStoredClientSecret, 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_tokenwith each call, so a single leaked refresh-token grants indefinite access until the row is revoked or itsrefreshTokenExpiresAt(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_codegrant uses raw===for client-secret comparison and ignores thestoreClientSecret: "encrypted" | "hashed"configuration; the fix routes both grants throughverifyStoredClientSecret. The mcp/mcp/tokenendpoint setsAccess-Control-Allow-Origin: *unconditionally, which amplifies the refresh bypass in browser contexts; the fix narrows the CORS allowlist.The newer
@better-auth/oauth-providerpackage routes both grants throughvalidateClientCredentialsand is not affected.Patches
Fixed in
better-auth@1.6.11. The legacyoidcProviderandmcptoken endpoints now requireclient_secreton therefresh_tokengrant for confidential clients, using the same constant-time comparison theauthorization_codegrant already used. Public clients are unaffected (they have no secret to enforce, and PKCE substitutes on the auth-code grant).The
Authorization: Basicparser 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/tokenendpoint's CORS configuration is narrowed in the same change (the wildcardAccess-Control-Allow-Origin: *header is removed), matching the standalone@better-auth/oauth-providerpackage.The deprecated
oidc-providerplugin remains deprecated. The recommended migration path is@better-auth/oauth-provider.Workarounds
None of these close the bug fully without a code patch.
@better-auth/oauth-providerif your deployment can adopt the new plugin. It enforcesclient_secreton both grants.type: "public"and require PKCE. The bug is unreachable when there is noclient_secretto verify./api/auth/oauth2/tokenand/api/auth/mcp/tokento known client IPs at the load balancer. Practical for server-to-server flows, not for end-user-device clients.db.deleteMany({ model: "oauthAccessToken", where: [{ field: "clientId", value: <id> }] })to invalidate all refresh tokens for the affected client.Impact
refresh_tokenand the publicclient_idcan mint access tokens and rotated refresh tokens indefinitely, until the row is revoked. Rotation refreshes the expiration window each call.