chore(deps): update dependency better-auth to v1.6.13 [security] - #59
Open
renovate[bot] wants to merge 1 commit into
Open
chore(deps): update dependency better-auth to v1.6.13 [security]#59renovate[bot] wants to merge 1 commit into
renovate[bot] wants to merge 1 commit into
Conversation
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
|
All alerts resolved. Learn more about Socket for GitHub. This PR previously contained dependency changes with security issues that have been resolved, removed, or ignored. |
renovate
Bot
force-pushed
the
renovate/npm-better-auth-vulnerability
branch
from
July 30, 2026 17:38
ad1b934 to
32256ff
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.4.12→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: Rate limiter keys IPv6 addresses individually and is bypassable via prefix rotation
CVE-2026-45364 / GHSA-p6v2-xcpg-h6xw
More information
Details
Am I affected?
Users are affected if all of the following are true:
better-authat a version< 1.4.17, or at a v1.5 prerelease tagged<= 1.5.0-beta.8.x-forwarded-forvalue (the stock setup) or any other configured IP-bearing header.If users are on
1.4.16specifically, thenormalizeIPhelper exists in your version but the IPv6 prefix length defaults to/128. Stock config still permits prefix rotation because no prefix mask is applied. Either upgrade to1.4.17or setadvanced.ipAddress.ipv6Subnet: 64in the config.If applications do not use the rate limiter, or if the deployment serves only IPv4 clients, the prefix-rotation vector does not apply. The representation-aliasing vector still applies to IPv6 addresses delivered over IPv4 transport in some edge cases (an upstream proxy carrying an IPv4-mapped IPv6 source), but it is rare in practice.
Fix:
better-auth@1.4.17or later. The current stable line1.6.xand the pre-release line1.7.0-betaboth carry the fix.Summary
Better Auth's HTTP rate limiter keyed each request by the exact textual IP address it received in
x-forwarded-for(or the configured IP-bearing header). IPv6 clients controlling a typical/64allocation could rotate through 2^64 distinct source addresses without exhausting the per-address counter, defeating rate limiting on/sign-in/email,/sign-up/email,/forget-password, and every other path the limiter protects. The same bug allowed a single client to vary the textual encoding of one IPv6 address (uppercase, compression, IPv4-mapped, hex-encoded IPv4-in-IPv6) and produce multiple distinct keys.Details
The pre-fix
getIpfunction returned the leftmostx-forwarded-forvalue verbatim after a single validity check, andonRequestRateLimitconstructed the rate-limit key by string concatenation of that value with the request path. Two facts of IPv6 made the key space larger than the population of clients:/56for residential users; cloud providers commonly assign/29to/48. An attacker controlling a single/64therefore controls 2^64 source addresses without doing anything unusual.::ffff:0:0/96IPv4-mapped addresses can be written as either dotted-decimal or hex-encoded.The fix in
better-auth@1.4.17introducesnormalizeIPand applies it to everygetIpresult. Normalization expands compressed IPv6 forms, lowercases hex digits, collapses IPv4-mapped IPv6 to plain IPv4, and applies a default/64prefix mask. The rate-limit key construction now uses an explicit|separator to prevent key-construction collisions across address-and-path joins.The
/64default matches the smallest commonly-allocated IPv6 unit, so a single client cannot use prefix rotation to defeat rate limiting on stock config. Operators who serve clients on coarser allocations (/56for residential ISPs, larger for cloud) can configureadvanced.ipAddress.ipv6Subnetaccordingly.Patches
Fixed in
better-auth@1.4.17on the v1.4.x maintenance line and inbetter-auth@1.5.0-beta.9on the v1.5.x line. PR #7470 introduced the normalization primitive (packages/core/src/utils/ip.ts) and applied it togetIpand the rate-limit key. PR #7509 changed the IPv6 prefix-length default from/128to/64so that stock config closes the prefix-rotation vector without requiring users to opt in.After the patch, the rate limiter treats all IPv6 addresses within a
/64allocation as a single client, all textual encodings of one IPv6 address as the same address, and all IPv4-mapped IPv6 addresses as their underlying IPv4 form.Workarounds
If users cannot upgrade past
1.4.17:>= 1.4.16: setadvanced.ipAddress.ipv6Subnet: 64in the auth configuration. ThenormalizeIPhelper is present at1.4.16; only the default is wrong. This restores the post-1.4.17behavior on stock config.< 1.4.16: shift the bypass mitigation upstream. Set the IPv6 prefix length on the app's CDN, WAF, or load balancer rate-limit policy to/64(or coarser per RFC 6177 if the app serves residential traffic). Cloudflare, Vercel Firewall, AWS WAF, and Google Cloud Armor all support per-prefix rate limiting.customRuleswindow for sign-in, sign-up, and password-reset endpoints. This narrows the abuse window but does not close it.Impact
The bypass enables unbounded authentication attempts from a single IPv6-capable client. Direct consequences:
/sign-in/emailare no longer rate-limited per client.The bypass does not directly compromise any account. Successful exploitation still requires the attacker to guess a credential the password store accepts. The rating reflects the loss of one defense-in-depth layer rather than a direct compromise.
Credit
Reported by
@nexryaion GitHub.Resources
Severity
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:L/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: 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 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 })