chore(deps): update dependency better-auth to v1.6.22 [security] - #164
Open
renovate[bot] wants to merge 1 commit into
Open
chore(deps): update dependency better-auth to v1.6.22 [security]#164renovate[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
June 11, 2026 19:55
9f136e4 to
f6c0423
Compare
renovate
Bot
force-pushed
the
renovate/npm-better-auth-vulnerability
branch
from
July 18, 2026 23:11
f6c0423 to
74aafa5
Compare
renovate
Bot
force-pushed
the
renovate/npm-better-auth-vulnerability
branch
2 times, most recently
from
July 24, 2026 18:34
2289878 to
b1c763c
Compare
renovate
Bot
force-pushed
the
renovate/npm-better-auth-vulnerability
branch
from
July 28, 2026 03:08
b1c763c to
dc7f5f2
Compare
renovate
Bot
force-pushed
the
renovate/npm-better-auth-vulnerability
branch
from
July 30, 2026 14:16
dc7f5f2 to
9b47a6b
Compare
renovate
Bot
force-pushed
the
renovate/npm-better-auth-vulnerability
branch
from
August 11, 2026 20:59
9b47a6b to
a475494
Compare
renovate
Bot
force-pushed
the
renovate/npm-better-auth-vulnerability
branch
from
August 26, 2026 20:45
a475494 to
adb80f7
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.6.9→1.6.22Better Auth: Device authorization approve and deny accept any authenticated session while the user code is pending
CVE-2026-45337 / GHSA-cq3f-vc6p-68fh
More information
Details
Am I affected?
You are affected if all of the following are true:
better-authat a version>= 1.6.0, < 1.6.11.deviceAuthorizationplugin is enabled in your auth config (deviceAuthorization()in yourpluginsarray).The standard device-flow UX displays user codes to humans, so realistic exposure includes shoulder-surfing, screen-share, voice or video calls, support-chat transcripts, referrer headers, and shared logs.
If your application does not enable the
deviceAuthorizationplugin, you are not affected.Fix:
better-auth@1.6.11or later.Summary
Better Auth's
deviceAuthorizationplugin treated any authenticated session as the owner of any pending device code. The ownership gate onPOST /device/approveandPOST /device/denyshort-circuited whenever the row'suserIdwas unset, and theGET /deviceverification handler did not claim the row. An authenticated attacker who learned a validuser_codebefore the legitimate user completed approval could bind the polling device to the attacker's account or deny the legitimate flow.Details
The device authorization flow binds the polling device to the user who entered the user code on the verification page. In affected versions, the plugin only created that binding at approve or deny time, with no claim at the verification step. The ownership check at approve and deny short-circuited when the owner was missing, accepting any authenticated caller instead of rejecting the request.
The fix changes
GET /deviceto claim the pending row for the calling session. The approve and deny gates now require strict equality between the row's owner and the calling session. RFC 8628 §5.5 covers this risk class as Session Spying: a malicious party can hijack a session by completing authorization before the legitimate initiating user does.Patches
Fixed in
better-auth@1.6.11. After the patch,GET /deviceclaims the pending row for the calling session, andPOST /device/approveandPOST /device/denyreject calls whose session does not match the claimed owner. Custom verification pages must serveGET /deviceto an authenticated session for the flow to succeed.Workarounds
If you cannot upgrade immediately:
deviceAuthorization()from yourpluginsarray.beforehook onPOST /device/approveandPOST /device/denythat tracks which session calledGET /devicefor each user code, and rejects calls from a different session.expiresInplugin option to reduce the exploitation window.Impact
Credit
Reported by Quikturn Security Team.
References
Severity
CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:U/C:H/I:H/A:LReferences
This data is provided by 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.Credit
Reported by @subhanUmer.
Resources
Severity
CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:H/VA:N/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:XReferences
This data is provided by 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 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 the GitHub Advisory Database (CC-BY 4.0).
Better Auth has stored XSS in the auth-server origin via javascript: redirect_uri in oidc-provider and mcp
GHSA-86j7-9j95-vpqj
More information
Details
Am I affected?
Check each condition. Users are affected when all of the first three hold.
oidc-providerplugin or themcpplugin frombetter-auth/plugins. Themcpplugin wraps the same provider and carries the same defect. Both are on the migration path to@better-auth/oauth-provider, which is not affected.better-authversion is1.6.12or earlier on the stable line, or any1.7.0-betabuild on the pre-release line. Both release lines carry the same defect.redirectURIfrom theauthClient.oauth2.consent(...)response and assigns it to a browser navigation target such aswindow.location.href,location.assign, orlocation.replace.Two conditions raise an application's exposure:
allowDynamicClientRegistration: true. Client registration is then unauthenticated, so any visitor can plant the malicious client. The default isfalse, which limits planting to authenticated users.A developer's application is not affected when any of these hold:
consent-buttons.tsxdemo verbatim. That file reads aurifield that this plugin never returns, so its navigation branch never runs and it falls through to an error toast.javascript:URL delivered in aLocationresponse header.@better-auth/oauth-providerinstead of the deprecatedoidc-providerplugin.Fix:
better-auth@1.6.13(stable) or1.7.0-beta.4(pre-release).Summary
The deprecated
oidc-providerplugin registers OAuth clients without validating the scheme of theirredirect_uris. An attacker stores ajavascript:URI as a client redirect target, and the authorization server later returns that URI to the browser in the consent response. A consent page that navigates to the returned value then executes attacker JavaScript in the authorization-server origin, which exposes the victim's session and enables account takeover. Themcpplugin wraps the same provider and carries the same defect, so MCP server deployments are affected as well; likeoidc-provider, it is migrating to@better-auth/oauth-provider.Details
Client registration accepts any string. The registration body schema types
redirect_urisasz.array(z.string())with no scheme check, soPOST /oauth2/registerstores a value such asjavascript:fetch('/api/auth/get-session')//. In the default configuration,allowDynamicClientRegistrationisfalse, so registration requires an authenticated session; any logged-in user qualifies. With dynamic client registration enabled, registration is unauthenticated.The dangerous value then survives the authorization-code flow unchanged.
GET /oauth2/authorizematches the requestredirect_uriagainst the stored list by exact string equality, so the registeredjavascript:URI matches itself and is written into the consent verification record. When the user approves on the consent screen, the handler runsnew URL(value.redirectURI), appends the authorization code throughsearchParams.set, and returns the result in JSON under the keyredirectURI. Thejavascript:scheme passes throughnew URLintact, and a trailing//in the payload comments out the appended query string.The plugin documents the consent call but not the redirect step that follows it, and it gives no warning that
redirectURIcan carry a dangerous scheme. The natural way an operator completes the flow is to assignres.data.redirectURItowindow.location.href. Per the URL specification and browser behavior, navigatingwindow.locationto ajavascript:URL executes the script body in the current document origin, which here is the authorization server. The injected script can call/api/auth/get-sessionand any other session-scoped endpoint in that origin.The sibling
@better-auth/oauth-providerpackage already prevents this. It validates the same field withSafeUrlSchema, which rejects thejavascript:,data:, andvbscript:schemes and requires HTTPS or a loopback host. The deprecated plugin never received that control; the field shipped unvalidated when the registration endpoint was first added, well before the sibling package introducedSafeUrlSchema.Patches
Fixed in
better-auth@1.6.13(stable) and1.7.0-beta.4(pre-release). The fix adds scheme validation to theredirect_urisof both theoidc-providerandmcpplugins, matching the control@better-auth/oauth-provideralready enforces.Workarounds
If developers cannot upgrade, apply one of the following.
http:orhttps:. For example, reject the value whennew URL(redirectURI).protocolis neitherhttp:norhttps:, and show an error instead of navigating. This closes the sink regardless of what the server returns.@better-auth/oauth-provider. It validates redirect URIs at registration and is the supported replacement for the deprecated plugin.allowDynamicClientRegistrationat its default offalseand restrict who can register clients. This does not remove the issue, because any authenticated user can still register a malicious client, but it removes the unauthenticated path.Impact
This is a stored DOM cross-site scripting issue in the authorization server's own origin, which leads to account takeover. An attacker who can register a client plants a
javascript:redirect URI. A victim who visits the attacker's authorize URL and approves consent runs attacker JavaScript in the authorization-server origin. From there the script reads/api/auth/get-session, calls any session-scoped endpoint, and takes over the account. The consent screen shows the attacker-controlled client name, which makes the approval click easy to socially engineer.The realized impact depends on one operator-side precondition: the consent page must assign the returned
redirectURIto a navigation target. Better Auth itself never performs that navigation, which is why the assessed Attack Complexity is High and the score sits below a pure server-side execution flaw. The deprecated status of theoidc-providerplugin does not reduce the impact. The plugin remains published, importable, and documented, and users on the affected versions cannot opt out without changing their integration. Themcpplugin wraps the same provider and is affected on the same versions; its docs announce a move to@better-auth/oauth-provider, but until users migrate or upgrade, the affected published versions stay exposed.@better-auth/oauth-providervalidates redirect URIs and provides MCP support, so it is the migration target for both plugins.Credit
Reported by @hillalee
Resources
window.locationto ajavascript:URL executes the script body. https://developer.mozilla.org/en-US/docs/Web/URI/Reference/Schemes/javascriptSeverity
CVSS:3.1/AV:N/AC:H/PR:L/UI:R/S:C/C:H/I:H/A:NReferences
This data is provided by 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 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 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 the GitHub Advisory Database (CC-BY 4.0).
Better Auth: Account takeover via pre-account hijacking on magic-link and email-OTP sign-in
GHSA-qq9h-g4jm-xgf3
More information
Details
Am I affected
You are affected if all of the following hold:
better-authversion below 1.6.22, or a1.7.0-betabelow1.7.0-beta.10.Summary
An attacker can keep password access to a victim's account after the victim starts using it. The attack runs in three steps. First, with open registration, the attacker signs up using the victim's email and a password the attacker picks. The account stays unverified, so the attacker cannot use it yet. Later the real owner signs in with a magic link or an email OTP. That step marks the account verified, and the attacker's password now works on it.
Details
When an account already exists for an address, magic-link verification and email-OTP sign-in both sign in to that account. They mark it verified and issue a session. Before the fix, neither one removed a password set while the account was still unverified. Neither one revoked existing sessions. So a password set before anyone proved control of the mailbox kept working after the owner proved control.
This is a pre-account-hijacking weakness. It is the same class as the OAuth advisory GHSA-g38m-r43w-p2q7, applied to the passwordless email flows. Requiring email verification does not stop it. The verification step is the exact moment that turns the planted account into a usable one.
The fix treats current proof of control over the address as authoritative. When either flow finds an account whose email was never confirmed, it now removes the password and revokes existing sessions first. Only then does it mark the account verified and issue the new session. The owner signs in as before. Only access created before the proof is removed.
Patches
Upgrade to
better-auth1.6.22 or later on the stable line. On the 1.7 pre-release line, upgrade to1.7.0-beta.10or later. Earlier 1.7 betas, up to and including1.7.0-beta.9, still contai