Skip to content

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

Merged
KyleAMathews merged 3 commits into
mainfrom
renovate/npm-better-auth-vulnerability
Oct 5, 2026
Merged

KyleAMathews merged 3 commits into
mainfrom
renovate/npm-better-auth-vulnerability

Conversation

@renovate

@renovate renovate Bot commented Oct 3, 2026

Copy link
Copy Markdown
Contributor

ℹ️ Note

This PR body was truncated due to platform limits.

This PR contains the following updates:

Package Change Age Confidence
better-auth (source) 1.3.4 → 1.6.22 age confidence

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 Score: 8.6 / 10 (High)
  • Vector String: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:L/A:L

References

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 baseURL when 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 baseURL is not explicitly configured (e.g., BETTER_AUTH_URL is 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-Host and X-Forwarded-Proto is implicitly trusted. This allows the first request to configure baseURL whenever it is not explicitly configured.

Here's the code that reads the headers:

headers

Here's the call to getBaseURL(), the result is assigned to ctx.baseURL.

write

Here's the router receiving the poisoned basePath:

router

X-Forwarded-Host and X-Forwarded-Proto can be used to modify the pathname of a parsed URL object which forms baseURL. basePath is then derived from the pathname of baseURL. Once the router basePath is poisoned it fails to match & route incoming requests.

Repro

Start a better-auth server with no baseURL configuration.

Send the following request as the first request to the server:

curl -i --location 'https://example.com/api/auth/ok' \
--header 'X-Forwarded-Proto: some:' \
--header 'X-Forwarded-Host: junk'

The better-auth API check endpoint returns 404.

Now send a regular request without the X-Forwarded-Proto and X-Forwarded-Host headers.

curl -i --location 'https://example.com/api/auth/ok'

The better-auth API check endpoint still returns 404.

Example result

attack

We have modified the basePath for the router until the server is restarted. An attacker can repeatedly send these attack requests aiming to persistently exploit the vulnerability.

Severity

  • CVSS Score: 8.9 / 10 (High)
  • Vector String: 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:P

References

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 /path and //path to be treated as identical routes. If your environment does not normalize incoming URLs (e.g., by collapsing multiple slashes), this can allow bypasses of disabledPaths and 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 //path reach Better Auth as /path, the issue does not apply.

Fix

Updating rou3 to the latest version resolves the issue:

Better Auth recommends:

  1. Upgrading to Better Auth v1.4.5 or later, which includes the updated rou3.
  2. Ensuring the proxy normalizes URLs.
  3. If project maintainers cannot upgrade yet, they can protect their app by normalizing url before it reaches better-auth handler. See example below:
const req = new Request(...) // this would be the actual request object
const url = new URL(req.url);
const normalizedPath = url.pathname.replace(/\/+/g, "/");

if (url.pathname !== normalizedPath) {
  url.pathname = normalizedPath;
  // Update the raw request pathname
  Object.defineProperty(req, "url", {
    value: url.toString(),
    writable: true,
    configurable: true,
  });
}
Impact
  • Bypass disabledPaths
  • Bypass path-based rate limits

The impact of bypassing disabled paths could vary based on a project's configuration.

Severity

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

References

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.cookieCache is 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-auth with both two-factor authentication and session cookie caching enabled may be affected.


Mitigation
  • Upgrade to a version of better-auth that includes the fix for this issue.
  • Ensure that session caching does not treat sessions as fully authenticated until all required authentication steps, including 2FA, are completed.
  • As a temporary workaround, disable session.cookieCache when using two-factor authentication.

Severity

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

References

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


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

GHSA-wxw3-q3m9-c3jr

More information

Details

Am I affected?

Users are affected if all of the following are true:

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

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

Fix:

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

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

Details

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

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

Patches

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

Workarounds

If users cannot upgrade immediately:

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

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

Resources

Severity

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

References

This data is provided by 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:

  • Their app uses better-auth at a version < 1.4.17, or at a v1.5 prerelease tagged <= 1.5.0-beta.8.
  • The apps authentication endpoints serve clients reachable over IPv6. Most managed hosts including Cloudflare, Vercel, Fly.io, AWS Application Load Balancer, and Google Cloud Load Balancing advertise IPv6 by default.
  • The app's rate-limit configuration is enabled (the production default) and relies on the leftmost x-forwarded-for value (the stock setup) or any other configured IP-bearing header.

If users are on 1.4.16 specifically, the normalizeIP helper 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 to 1.4.17 or set advanced.ipAddress.ipv6Subnet: 64 in 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:

  1. Upgrade to better-auth@1.4.17 or later. The current stable line 1.6.x and the pre-release line 1.7.0-beta both carry the fix.
  2. If applications cannot upgrade, see workarounds below.
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 /64 allocation 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 getIp function returned the leftmost x-forwarded-for value verbatim after a single validity check, and onRequestRateLimit constructed 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:

  • ISPs and cloud providers assign prefixes, not addresses. RFC 6177 recommends /56 for residential users; cloud providers commonly assign /29 to /48. An attacker controlling a single /64 therefore controls 2^64 source addresses without doing anything unusual.
  • IPv6 has multiple textual representations for the same address. RFC 5952 specifies a canonical form, but RFC 4291 §2.2 permits the older mixed forms, and ::ffff:0:0/96 IPv4-mapped addresses can be written as either dotted-decimal or hex-encoded.

The fix in better-auth@1.4.17 introduces normalizeIP and applies it to every getIp result. Normalization expands compressed IPv6 forms, lowercases hex digits, collapses IPv4-mapped IPv6 to plain IPv4, and applies a default /64 prefix mask. The rate-limit key construction now uses an explicit | separator to prevent key-construction collisions across address-and-path joins.

The /64 default 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 (/56 for residential ISPs, larger for cloud) can configure advanced.ipAddress.ipv6Subnet accordingly.

Patches

Fixed in better-auth@1.4.17 on the v1.4.x maintenance line and in better-auth@1.5.0-beta.9 on the v1.5.x line. PR #​7470 introduced the normalization primitive (packages/core/src/utils/ip.ts) and applied it to getIp and the rate-limit key. PR #​7509 changed the IPv6 prefix-length default from /128 to /64 so 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 /64 allocation 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:

  • On >= 1.4.16: set advanced.ipAddress.ipv6Subnet: 64 in the auth configuration. The normalizeIP helper is present at 1.4.16; only the default is wrong. This restores the post-1.4.17 behavior on stock config.
  • On < 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.
  • As a partial mitigation on any version: tighten the customRules window 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:

  • Credential-stuffing and brute-force on /sign-in/email are no longer rate-limited per client.
  • Account enumeration via response-shape differences becomes faster.
  • Password-reset and email-verification email fan-out can be amplified.

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 @nexryai on GitHub.

Resources

Severity

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

References

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:

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

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

Fix:

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

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

Details

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

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

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

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

Patches

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

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

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

Workarounds

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

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

Reported by @​subhanUmer.

Resources

Severity

  • CVSS Score: 9.1 / 10 (Critical)
  • Vector String: 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:X

References

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:

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

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

Fix:

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

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

Details

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

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

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

Patches

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

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

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

Workarounds

If developers cannot upgrade their applications immediately:

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

Reported by @​widavies.

Resources

Severity

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

References

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


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

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

More information

Details

Am I affected?

Users are affected if all of the following are true:

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

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

Fix:

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

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

Details

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

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

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

Patches

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

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

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

Workarounds

If developers cannot upgrade their applications immediately:

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

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

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

Reported by @​avrmeduard.

Resources

Severity

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

References

This data is provided by 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.

  • Their application enables the oidc-provider plugin or the mcp plugin from better-auth/plugins. The mcp plugin wraps the same provider and carries the same defect. Both are on the migration path to @better-auth/oauth-provider, which is not affected.
  • Their application better-auth version is 1.6.12 or earlier on the stable line, or any 1.7.0-beta build on the pre-release line. Both release lines carry the same defect.
  • Their application consent page is a client component that reads redirectURI from the authClient.oauth2.consent(...) response and assigns it to a browser navigation target such as window.location.href, location.assign, or location.replace.

Two conditions raise an application's exposure:

  • It sets allowDynamicClientRegistration: true. Client registration is then unauthenticated, so any visitor can plant the malicious client. The default is false, which limits planting to authenticated users.
  • It exposes the consent page to ordinary end users rather than to a restricted operator audience.

A developer's application is not affected when any of these hold:

  • Their application copied the project's consent-buttons.tsx demo verbatim. That file reads a uri field that this plugin never returns, so its navigation branch never runs and it falls through to an error toast.
  • Their application completse the consent flow with a server-side redirect, for example a Next.js or Remix server action. Browsers do not follow a javascript: URL delivered in a Location response header.
  • Their application uses @better-auth/oauth-provider instead of the deprecated oidc-provider plugin.

Fix:

  1. Upgrade to better-auth@1.6.13 (stable) or 1.7.0-beta.4 (pre-release).
  2. If developers cannot upgrade their applications, see the workarounds below.
Summary

The deprecated oidc-provider plugin registers OAuth clients without validating the scheme of their redirect_uris. An attacker stores a javascript: 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. The mcp plugin wraps the same provider and carries the same defect, so MCP server deployments are affected as well; like oidc-provider, it is migrating to @better-auth/oauth-provider.

Details

Client registration accepts any string. The registration body schema types redirect_uris as z.array(z.string()) with no scheme check, so POST /oauth2/register stores a value such as javascript:fetch('/api/auth/get-session')//. In the default configuration, allowDynamicClientRegistration is false, 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/authorize matches the request redirect_uri against the stored list by exact string equality, so the registered javascript: URI matches itself and is written into the consent verification record. When the user approves on the consent screen, the handler runs new URL(value.redirectURI), appends the authorization code through searchParams.set, and returns the result in JSON under the key redirectURI. The javascript: scheme passes through new URL intact, 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 redirectURI can carry a dangerous scheme. The natural way an operator completes the flow is to assign res.data.redirectURI to window.location.href. Per the URL specification and browser behavior, navigating window.location to a javascript: URL executes the script body in the current document origin, which here is the authorization server. The injected script can call /api/auth/get-session and any other session-scoped endpoint in that origin.

The sibling @better-auth/oauth-provider package already prevents this. It validates the same field with SafeUrlSchema, which rejects the javascript:, data:, and vbscript: 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 introduced SafeUrlSchema.

Patches

Fixed in better-auth@1.6.13 (stable) and 1.7.0-beta.4 (pre-release). The fix adds scheme validation to the redirect_uris of both the oidc-provider and mcp plugins, matching the control @better-auth/oauth-provider already enforces.

Workarounds

If developers cannot upgrade, apply one of the following.

  • Harden the consent page. Before navigating, parse the returned value and navigate only when its scheme is http: or https:. For example, reject the value when new URL(redirectURI).protocol is neither http: nor https:, and show an error instead of navigating. This closes the sink regardless of what the server returns.
  • Migrate to @better-auth/oauth-provider. It validates redirect URIs at registration and is the supported replacement for the deprecated plugin.
  • Keep allowDynamicClientRegistration at its default of false and 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 redirectURI to 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 the oidc-provider plugin 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. The mcp plugin 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-provider validates redirect URIs and provides MCP support, so it is the migration target for both plugins.

Credit

Reported by @​hillalee

Resources

Severity

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

References

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


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

GHSA-9h47-pqcx-hjr4

More information

Details

Am I affected?

Users are affected if all of the following are true:

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

If the application only uses `@better-aut

❗ Important

✂ PR body was truncated to here.

@renovate renovate Bot added the dependencies label Oct 3, 2026
@changeset-bot

changeset-bot Bot commented Oct 3, 2026 •

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 9ed692f

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@coderabbitai

coderabbitai Bot commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

Important

Review skipped

Bot user detected.

To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration
  • Configuration used: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 7a898e82-2b5a-40cd-b644-46d75bab4d51

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@socket-security

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addednpm/​better-auth@​1.6.22981008596100

View full report

@pkg-pr-new

pkg-pr-new Bot commented Oct 5, 2026

Copy link
Copy Markdown
More templates

@tanstack/angular-db

npm i https://pkg.pr.new/@tanstack/angular-db@2019

@tanstack/browser-db-sqlite-persistence

npm i https://pkg.pr.new/@tanstack/browser-db-sqlite-persistence@2019

@tanstack/capacitor-db-sqlite-persistence

npm i https://pkg.pr.new/@tanstack/capacitor-db-sqlite-persistence@2019

@tanstack/cloudflare-durable-objects-db-sqlite-persistence

npm i https://pkg.pr.new/@tanstack/cloudflare-durable-objects-db-sqlite-persistence@2019

@tanstack/db

npm i https://pkg.pr.new/@tanstack/db@2019

@tanstack/db-ivm

npm i https://pkg.pr.new/@tanstack/db-ivm@2019

@tanstack/db-sqlite-persistence-core

npm i https://pkg.pr.new/@tanstack/db-sqlite-persistence-core@2019

@tanstack/electric-db-collection

npm i https://pkg.pr.new/@tanstack/electric-db-collection@2019

@tanstack/electron-db-sqlite-persistence

npm i https://pkg.pr.new/@tanstack/electron-db-sqlite-persistence@2019

@tanstack/expo-db-sqlite-persistence

npm i https://pkg.pr.new/@tanstack/expo-db-sqlite-persistence@2019

@tanstack/node-db-sqlite-persistence

npm i https://pkg.pr.new/@tanstack/node-db-sqlite-persistence@2019

@tanstack/offline-transactions

npm i https://pkg.pr.new/@tanstack/offline-transactions@2019

@tanstack/powersync-db-collection

npm i https://pkg.pr.new/@tanstack/powersync-db-collection@2019

@tanstack/query-db-collection

npm i https://pkg.pr.new/@tanstack/query-db-collection@2019

@tanstack/react-db

npm i https://pkg.pr.new/@tanstack/react-db@2019

@tanstack/react-native-db-sqlite-persistence

npm i https://pkg.pr.new/@tanstack/react-native-db-sqlite-persistence@2019

@tanstack/react-router-with-db

npm i https://pkg.pr.new/@tanstack/react-router-with-db@2019

@tanstack/rxdb-db-collection

npm i https://pkg.pr.new/@tanstack/rxdb-db-collection@2019

@tanstack/solid-db

npm i https://pkg.pr.new/@tanstack/solid-db@2019

@tanstack/svelte-db

npm i https://pkg.pr.new/@tanstack/svelte-db@2019

@tanstack/tauri-db-sqlite-persistence

npm i https://pkg.pr.new/@tanstack/tauri-db-sqlite-persistence@2019

@tanstack/trailbase-db-collection

npm i https://pkg.pr.new/@tanstack/trailbase-db-collection@2019

@tanstack/vue-db

npm i https://pkg.pr.new/@tanstack/vue-db@2019

commit: 9ed692f

@github-actions

github-actions Bot commented Oct 5, 2026

Copy link
Copy Markdown
Contributor

Size Change: 0 B

Total Size: 180 kB

ℹ️ View Unchanged
Filename Size
packages/db/dist/esm/client.js 3.61 kB
packages/db/dist/esm/collection-options.js 236 B
packages/db/dist/esm/collection/change-events.js 1.64 kB
packages/db/dist/esm/collection/changes.js 2.38 kB
packages/db/dist/esm/collection/cleanup-queue.js 808 B
packages/db/dist/esm/collection/events.js 481 B
packages/db/dist/esm/collection/index.js 4.57 kB
packages/db/dist/esm/collection/indexes.js 2.06 kB
packages/db/dist/esm/collection/lifecycle.js 2.66 kB
packages/db/dist/esm/collection/mutations.js 3.07 kB
packages/db/dist/esm/collection/state.js 8.27 kB
packages/db/dist/esm/collection/subscription.js 8.44 kB
packages/db/dist/esm/collection/sync.js 5.37 kB
packages/db/dist/esm/collection/transaction-metadata.js 144 B
packages/db/dist/esm/deferred.js 207 B
packages/db/dist/esm/errors.js 5.61 kB
packages/db/dist/esm/event-emitter.js 961 B
packages/db/dist/esm/index.js 4.01 kB
packages/db/dist/esm/indexes/auto-index.js 841 B
packages/db/dist/esm/indexes/base-index.js 1.25 kB
packages/db/dist/esm/indexes/basic-index.js 2.01 kB
packages/db/dist/esm/indexes/btree-index.js 2.3 kB
packages/db/dist/esm/indexes/index-registry.js 820 B
packages/db/dist/esm/indexes/reverse-index.js 370 B
packages/db/dist/esm/live-query-adapter.js 338 B
packages/db/dist/esm/live-query-observer.js 4.59 kB
packages/db/dist/esm/live-query-options.js 1.06 kB
packages/db/dist/esm/live-query-window-controller.js 4.12 kB
packages/db/dist/esm/local-only.js 1.08 kB
packages/db/dist/esm/local-storage.js 2.17 kB
packages/db/dist/esm/optimistic-action.js 359 B
packages/db/dist/esm/paced-mutations.js 702 B
packages/db/dist/esm/persisted-readiness.js 195 B
packages/db/dist/esm/proxy.js 3.17 kB
packages/db/dist/esm/query/builder/clone-query.js 766 B
packages/db/dist/esm/query/builder/functions.js 1.45 kB
packages/db/dist/esm/query/builder/index.js 6.81 kB
packages/db/dist/esm/query/builder/query-ir.js 116 B
packages/db/dist/esm/query/builder/ref-proxy-identity.js 292 B
packages/db/dist/esm/query/builder/ref-proxy.js 1.48 kB
packages/db/dist/esm/query/builder/wrapper-identity.js 221 B
packages/db/dist/esm/query/compiler/evaluators.js 2.1 kB
packages/db/dist/esm/query/compiler/expressions.js 604 B
packages/db/dist/esm/query/compiler/group-by.js 4.2 kB
packages/db/dist/esm/query/compiler/index.js 9.39 kB
packages/db/dist/esm/query/compiler/joins.js 3.06 kB
packages/db/dist/esm/query/compiler/lazy-targets.js 1.14 kB
packages/db/dist/esm/query/compiler/order-by.js 2 kB
packages/db/dist/esm/query/compiler/parent-routes.js 319 B
packages/db/dist/esm/query/compiler/query-equivalence.js 455 B
packages/db/dist/esm/query/compiler/route-metadata.js 1.24 kB
packages/db/dist/esm/query/compiler/select.js 1.59 kB
packages/db/dist/esm/query/effect.js 4.86 kB
packages/db/dist/esm/query/equality-conjunct.js 486 B
packages/db/dist/esm/query/equality-value-identity.js 591 B
packages/db/dist/esm/query/expression-helpers.js 1.45 kB
packages/db/dist/esm/query/ir-stable-identity.js 4.22 kB
packages/db/dist/esm/query/ir.js 1.7 kB
packages/db/dist/esm/query/live-query-collection.js 391 B
packages/db/dist/esm/query/live/bucket-facade-adapter.js 2.67 kB
packages/db/dist/esm/query/live/collection-config-builder.js 6.47 kB
packages/db/dist/esm/query/live/collection-registry.js 264 B
packages/db/dist/esm/query/live/collection-subscriber.js 2.05 kB
packages/db/dist/esm/query/live/graph-scheduler.js 305 B
packages/db/dist/esm/query/live/internal.js 145 B
packages/db/dist/esm/query/live/materialized-pipeline.js 2.32 kB
packages/db/dist/esm/query/live/ordered-source-loader.js 4.14 kB
packages/db/dist/esm/query/live/subset-demand-controller.js 1.65 kB
packages/db/dist/esm/query/live/utils.js 1.2 kB
packages/db/dist/esm/query/optimizer.js 2.92 kB
packages/db/dist/esm/query/pooled-live-query.js 4.06 kB
packages/db/dist/esm/query/query-once.js 359 B
packages/db/dist/esm/query/runtime-reference-identity.js 630 B
packages/db/dist/esm/query/subset-dedupe.js 493 B
packages/db/dist/esm/scheduler.js 1.13 kB
packages/db/dist/esm/SortedMap.js 1.6 kB
packages/db/dist/esm/strategies/debounceStrategy.js 331 B
packages/db/dist/esm/strategies/queueStrategy.js 488 B
packages/db/dist/esm/strategies/throttleStrategy.js 386 B
packages/db/dist/esm/sync-persistence.js 530 B
packages/db/dist/esm/transactions.js 3.89 kB
packages/db/dist/esm/utils.js 1.49 kB
packages/db/dist/esm/utils/array-utils.js 270 B
packages/db/dist/esm/utils/browser-polyfills.js 304 B
packages/db/dist/esm/utils/btree.js 3.02 kB
packages/db/dist/esm/utils/callbacks.js 174 B
packages/db/dist/esm/utils/comparison.js 1.59 kB
packages/db/dist/esm/utils/cursor.js 677 B
packages/db/dist/esm/utils/error.js 167 B
packages/db/dist/esm/utils/get-or-create.js 155 B
packages/db/dist/esm/utils/index-optimization.js 2.42 kB
packages/db/dist/esm/utils/source-record.js 140 B
packages/db/dist/esm/utils/type-guards.js 230 B
packages/db/dist/esm/utils/uuid.js 449 B
packages/db/dist/esm/virtual-props.js 413 B

compressed-size-action::db-package-size

@github-actions

github-actions Bot commented Oct 5, 2026

Copy link
Copy Markdown
Contributor

Size Change: 0 B

Total Size: 8.66 kB

ℹ️ View Unchanged
Filename Size
packages/react-db/dist/esm/DbProvider.js 317 B
packages/react-db/dist/esm/development.js 190 B
packages/react-db/dist/esm/HydrationBoundary.js 263 B
packages/react-db/dist/esm/index.js 330 B
packages/react-db/dist/esm/live-query-internals.js 282 B
packages/react-db/dist/esm/useLiveInfiniteQuery.js 1.93 kB
packages/react-db/dist/esm/useLiveQuery.js 3.27 kB
packages/react-db/dist/esm/useLiveQueryEffect.js 355 B
packages/react-db/dist/esm/useLiveSuspenseQuery.js 1.33 kB
packages/react-db/dist/esm/usePacedMutations.js 401 B

compressed-size-action::react-db-package-size

@KyleAMathews
KyleAMathews merged commit f9c5b52 into main Oct 5, 2026
12 checks passed
@KyleAMathews
KyleAMathews deleted the renovate/npm-better-auth-vulnerability branch October 5, 2026 15:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant