chore(monorepo): update pnpm.catalog.default better-auth to v1.6.22 [security] - #4
renovate[bot] wants to merge 1 commit into
Conversation
There was a problem hiding this comment.
Hello, I'm 🤖 Stormie-Bot! The Storm team sincerely appreciates your effort/interest in contributing to this project. A Storm developer will review this change and get back to you ASAP. Please feel free to reach out to the Storm team (contact@stormsoftware.com) if you have any questions/comments.
3a30269 to
82911dd
Compare
82911dd to
80dd773
Compare
|
Warning Review the following alerts detected in dependencies. According to your organization's Security Policy, it is recommended to resolve "Warn" alerts. Learn more about Socket for GitHub.
|
80dd773 to
300f21a
Compare
ebac748 to
3de6e7e
Compare
3de6e7e to
499c695
Compare
499c695 to
530c741
Compare
530c741 to
36dff43
Compare
36dff43 to
64d4a1a
Compare
64d4a1a to
b0d22b2
Compare
2789f53 to
2eb8ace
Compare
2eb8ace to
68abb3b
Compare
3e5a3a2 to
4cb3d8d
Compare
4cb3d8d to
dff958c
Compare
dff958c to
1177781
Compare
1177781 to
9ac0d32
Compare
9ac0d32 to
922fcb6
Compare
922fcb6 to
dfced0e
Compare
630241b to
398ad27
Compare
398ad27 to
4cc5d0e
Compare
cddcba3 to
509f3d3
Compare
509f3d3 to
db844ec
Compare
db844ec to
32eb139
Compare
32eb139 to
cdb029c
Compare
cdb029c to
4941143
Compare
4941143 to
ada5a3e
Compare
ada5a3e to
5586f48
Compare
This PR contains the following updates:
1.2.5→1.6.22Better Auth Open Redirect Vulnerability in originCheck Middleware Affects Multiple Routes
CVE-2025-53535 / GHSA-36rg-gfq2-3h56
More information
Details
Summary
An open redirect has been found in the
originCheckmiddleware function, which affects the following routes:/verify-email,/reset-password/:token,/delete-user/callback,/magic-link/verify,/oauth-proxy-callback.Details
In the
matchesPatternfunction,url.startsWith(can be deceived with aurlthat starts with one of thetrustedOrigins.Open Redirect PoCs
/reset-password/:token/verify-email/delete-user/callback/magic-link/verify/oauth-proxy-callbackImpact
Untrusted open redirects in various routes.
Severity
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:L/VI:N/VA:N/SC:N/SI:N/SA:N/E:P/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: Unauthenticated API key creation through api-key plugin
CVE-2025-61928 / GHSA-99h5-pjcv-gr6v
More information
Details
Summary
A critical authentication bypass was identified in the API key creation and update endpoints. An attacker could create or modify API keys for arbitrary users by supplying a victim’s user ID in the request body. Due to a flaw in how the authenticated user was derived, the endpoints could treat attacker-controlled input as an authenticated user object under certain conditions.
Details
The vulnerability originated from fallback logic used when determining the current user. When no session was present, the handler incorrectly allowed request-body data to populate the user context used for authorization decisions. Because server-side validation only executed when authentication was required, privileged fields were not properly protected. As a result, the API accepted unauthenticated requests that targeted other users.
This same pattern affected both the API key creation and update routes.
Impact
Unauthenticated attackers could generate or modify API keys belonging to any user. This granted full authenticated access as the targeted user and, depending on the user’s privileges, could lead to account compromise, access to sensitive data, or broader application takeover.
Severity
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:L/A:LReferences
This data is provided by the GitHub Advisory Database (CC-BY 4.0).
Better Auth affected by external request basePath modification DoS
CVE-2025-71401 / GHSA-569q-mpph-wgww
More information
Details
Summary
Affected versions of Better Auth allow an external request to configure
baseURLwhen it isn’t defined through any other means. This can be abused to poison the router’s base path, causing all routes to return 404 for all users.This issue is only exploitable when
baseURLis not explicitly configured (e.g.,BETTER_AUTH_URLis missing) and the attacker is able to make the very first request to the server after startup. In properly configured environments or typical managed hosting platforms, this fallback behavior cannot be reached.Details
A combination of
X-Forwarded-HostandX-Forwarded-Protois implicitly trusted. This allows the first request to configure baseURL whenever it is not explicitly configured.Here's the code that reads the headers:
Here's the call to
getBaseURL(), the result is assigned toctx.baseURL.Here's the router receiving the poisoned
basePath:X-Forwarded-HostandX-Forwarded-Protocan be used to modify the pathname of a parsed URL object which formsbaseURL.basePathis then derived from the pathname ofbaseURL. Once the routerbasePathis poisoned it fails to match & route incoming requests.Repro
Start a better-auth server with no
baseURLconfiguration.Send the following request as the first request to the server:
The better-auth API check endpoint returns 404.
Now send a regular request without the
X-Forwarded-ProtoandX-Forwarded-Hostheaders.The better-auth API check endpoint still returns 404.
Example result
We have modified the
basePathfor the router until the server is restarted. An attacker can repeatedly send these attack requests aiming to persistently exploit the vulnerability.Severity
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N/E:PReferences
This data is provided by the GitHub Advisory Database (CC-BY 4.0).
Better Auth's rou3 Dependency has Double-Slash Path Normalization which can Bypass disabledPaths Config and Rate Limits
GHSA-x732-6j76-qmhm
More information
Details
Summary
An issue in the underlying router library rou3 can cause
/pathand//pathto be treated as identical routes. If your environment does not normalize incoming URLs (e.g., by collapsing multiple slashes), this can allow bypasses ofdisabledPathsand path-based rate limits.Details
Better Auth uses better-call, which internally relies on rou3 for routing. Affected versions of rou3 normalize paths by removing empty segments. As a result:
/sign-in/email//sign-in/email///sign-in/email…all resolve to the same route.
Some production setups automatically collapse multiple slashes. This includes:
In these environments and other configurations where
//pathreach Better Auth as/path, the issue does not apply.Fix
Updating rou3 to the latest version resolves the issue:
"rou3": "^0.5.1"(commit: https://github.com/h3js/rou3/commit/f60b43fa648399534507c9ac7db36d705b8874c3)
Better Auth recommends:
Impact
disabledPathsThe impact of bypassing disabled paths could vary based on a project's configuration.
Severity
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:L/A:HReferences
This data is provided by the GitHub Advisory Database (CC-BY 4.0).
Better Auth Has Two-Factor Authentication Bypass via Premature Session Caching (session.cookieCache)
GHSA-xg6x-h9c9-2m83
More information
Details
Summary
Under certain configurations, sessions may be considered valid before two-factor authentication (2FA) is fully completed. This can allow access to authenticated routes without verifying the second factor.
Description
When two-factor authentication is enabled, the authentication flow correctly identifies users who require additional verification and defers full authentication until the second factor is completed.
However, when
session.cookieCacheis enabled, the session generated during the initial sign-in step may be cached as valid prior to 2FA verification. Subsequent session lookups may then return this cached session without re-evaluating the 2FA requirement.This results in a situation where session validity can be established before all authentication constraints are satisfied.
Impact
An attacker (or user) with valid primary credentials may gain access to protected application routes without completing the required second authentication factor.
Any application using
better-authwith both two-factor authentication and session cookie caching enabled may be affected.Mitigation
better-auththat includes the fix for this issue.session.cookieCachewhen using two-factor authentication.Severity
CVSS:4.0/AV:N/AC:H/AT:N/PR:N/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: 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 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