Skip to content

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

Open
renovate[bot] wants to merge 1 commit into
mainfrom
renovate/npm-better-auth-vulnerability
Open

renovate[bot] wants to merge 1 commit into
mainfrom
renovate/npm-better-auth-vulnerability

Conversation

@renovate

@renovate renovate Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

Note

This PR body was truncated due to platform limits.

This PR contains the following updates:

Package Change Age Confidence
better-auth (source) 1.6.11 → 1.7.7 age confidence

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: Account takeover via pre-account hijacking on magic-link and email-OTP sign-in

GHSA-qq9h-g4jm-xgf3

More information

Details

Am I affected

You are affected if all of the following hold:

  • You run a better-auth version below 1.6.22, or a 1.7.0-beta below 1.7.0-beta.10.
  • You enable the magic-link plugin or the email-OTP plugin.
  • You also enable email and password sign-up with open registration.
  • An account can exist at an address before its owner first signs in with the passwordless flow.
Summary

An attacker can keep password access to a victim's account after the victim starts using it. The attack runs in three steps. First, with open registration, the attacker signs up using the victim's email and a password the attacker picks. The account stays unverified, so the attacker cannot use it yet. Later the real owner signs in with a magic link or an email OTP. That step marks the account verified, and the attacker's password now works on it.

Details

When an account already exists for an address, magic-link verification and email-OTP sign-in both sign in to that account. They mark it verified and issue a session. Before the fix, neither one removed a password set while the account was still unverified. Neither one revoked existing sessions. So a password set before anyone proved control of the mailbox kept working after the owner proved control.

This is a pre-account-hijacking weakness. It is the same class as the OAuth advisory GHSA-g38m-r43w-p2q7, applied to the passwordless email flows. Requiring email verification does not stop it. The verification step is the exact moment that turns the planted account into a usable one.

The fix treats current proof of control over the address as authoritative. When either flow finds an account whose email was never confirmed, it now removes the password and revokes existing sessions first. Only then does it mark the account verified and issue the new session. The owner signs in as before. Only access created before the proof is removed.

Patches

Upgrade to better-auth 1.6.22 or later on the stable line. On the 1.7 pre-release line, upgrade to 1.7.0-beta.10 or later. Earlier 1.7 betas, up to and including 1.7.0-beta.9, still contain the bug.

Workarounds

If you cannot upgrade right away, you can lower the risk in two ways. Require email verification before you accept any password on an account. Remove unverified accounts quickly, so an address cannot sit reserved for long. Both steps shrink the opening, but they do not close it. Upgrading is the real fix.

Impact

The attacker gains lasting password access to an account the victim depends on, alongside the victim. From there the attacker can read and change the victim's data. The attacker can also reset the credentials and lock the victim out. The takeover only completes when the victim signs in through the passwordless flow. It also needs a setup that pairs that flow with open email and password sign-up.

Credit

Reported by the Vercel security team.

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: Device login approval does not show the requesting client or permissions

GHSA-q84f-53jg-9ppm

More information

Details

Am I affected?

You may be affected if you use better-auth from 1.3.8-beta.3 up to, but not including, 1.7.0-rc.3, and you enable the deviceAuthorization() plugin. This range includes all 1.6 releases.

You are affected only if all of these are also true:

  • An attacker can start a device request: validateClient is not configured, or the attacker can use a valid public client.
  • Before allowing approval, your approval page does not show, from server-checked data, which client is asking and the exact permissions it requests.
  • Users may approve a code or complete verification link they did not start and cannot compare with a device they have.
  • Your application accepts the session token that the device flow returns, for example through the bearer plugin.

You are not affected if you do not enable the opt-in deviceAuthorization() plugin.

Summary

The device authorization plugin lets a user sign in on one device by approving a short code on another. In affected versions, the approval information given to the user leaves out which client asked for access and which permissions it wants. The official approval examples therefore show only a generic request and a code, so users cannot tell their own device from an attacker's request.

Impact

An attacker who convinces a signed-in user to approve the attacker's code receives a live Better Auth session token for that user. The attacker can then read or change any data the user's account can access. The attack needs the opt-in device flow, a request the attacker creates, and a user who actively approves it; it is not a sign-in bypass without user action.

Patches

The fix first shipped in 1.7.0-rc.3. Upgrade to better-auth@1.7.0 or later. No 1.6 release contains the fix.

After upgrading, GET /device returns the requesting client_id and scope to the signed-in account that owns the request. If you built your own approval page, update it to show that client and those permissions, and keep approval disabled until they load.

Workarounds

If you cannot upgrade yet, do the following:

  • Disable deviceAuthorization(), or block untrusted access to /device/code.
  • Configure a strict validateClient allowlist. Do not rely on the allowlist alone: it limits who can start the flow, but it does not give users the missing approval information.
  • Build your own approval endpoint. It should read the pending request from trusted server-side storage and map the checked client identifier to a trusted display name. Show the exact requested permissions and the user code. Keep approval disabled until this information loads.
  • Tell users to reject device requests they did not expect. They should approve only when the same code is visible right now on a device they have.
Technical details

A device starts the flow by calling /device/code. The plugin stores the client identifier and any requested scope with the pending request. Before approval, GET /device returns only the user code and its status. This is true even for a signed-in session of the user account that owns the request. An approval page built from that response cannot show which client asked for access or which permissions it wants. After approval, the original device code can be redeemed at /device/token for a Better Auth session token.

RFC 8628 describes this remote phishing scenario. It recommends that the server tell users they are authorizing a device and ask them to confirm that the device is in their possession. It also recommends showing device information that helps users detect impersonation. Client allowlisting reduces who can start the flow, but it does not give users the missing approval information. A valid public client can therefore still be affected when its identifier and requested permissions are not shown.

Severity

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

References

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


better-auth: OAuth Proxy lets an attacker sign in as another user

GHSA-r4xp-prcw-77qf

More information

Details

Am I affected?

You may be affected if you use better-auth from 1.5.0-beta.12 through versions before 1.7.7 with the OAuth Proxy plugin. You are affected only if all of these are also true:

  • On a participating environment, the OAuth Proxy plugin has no dedicated secret, or its secret equals that environment's global Better Auth secret.
  • Sign-in state is stored in a cookie (account.storeStateStrategy: "cookie"). With a database or secondary storage, this is only the case if you chose it explicitly.
  • Implicit account linking is on (the default).

You are not affected by this path if every participating environment uses a proxy secret distinct from its global Better Auth secret, if sign-in state is stored in the database, or if you use a version before 1.5.0-beta.12.

Summary

The OAuth Proxy plugin trusts any user profile it can decrypt. In the affected setup, Better Auth encrypts sign-in state, including data the caller chose, with the same secret and hands it to anyone who starts a social sign-in. An attacker can use this to get a fake profile for another user's email address that the proxy accepts as real.

Impact

An attacker without an account who knows a user's verified email address can sign in as that user, with that user's permissions. They can read private data, change the account, and link a provider account of their choice to it. They need no provider sign-in and no action from the victim. No availability impact is established, and a reported open redirect was not confirmed.

Patches

The fix is merged for the planned better-auth 1.7.7 release. That package is not available yet.

Upgrade all nodes handling the same cookie-backed OAuth or SAML state flow and all OAuth Proxy participants together, including the production coordinator and preview or development deployments. Keep installed Better Auth packages released alongside better-auth on the same release version. Restart pending sign-in and account-linking flows; pre-upgrade state cookies and proxy payloads are rejected. Public options and API signatures are unchanged.

Workarounds

Until you upgrade to the fixed release, apply one of these:

  • Set one strong OAuth Proxy secret across every environment that takes part in the proxy flow, and keep it different from each environment's global Better Auth secret.
  • Set account.storeStateStrategy: "database". This is already the default when a database or secondary storage is configured. If Magic Link is also enabled, switching can expose a separate cross-purpose verification-record path in affected releases. Ensure the two flows resolve to distinct stored identifiers; with built-in options, use Magic Link storeToken: "hashed" with global verification.storeIdentifier unset or "plain", or disable Magic Link or both built-in social and Generic OAuth sign-in.
  • Remove the OAuth Proxy plugin if you do not need it.

Setting account.accountLinking.disableImplicitLinking: true stops this path from taking over an existing account. It does not fix the underlying flaw: the callback can still accept attacker-made data as a provider identity. Apply one of the steps above as well.

Severity

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

References

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


better-auth: OAuth state can be used as a Magic Link to sign in as another user

CVE-2026-114607 / GHSA-965c-763c-88jm

More information

Details

Am I affected?

You may be affected if you use better-auth from 1.4.0-beta.18 through versions before 1.7.7 with the Magic Link plugin and social or Generic OAuth sign-in.

You are affected if all of these are true:

  • Magic Link is enabled with default storeToken: "plain", or with storeToken: "hashed" and global verification.storeIdentifier: "hashed" (including a hashed default).
  • At least one built-in social provider or Generic OAuth provider is enabled.
  • OAuth state uses account.storeStateStrategy: "database", the default when a database or secondary storage is configured.

Cookie-backed state blocks this path, subject to the OAuth Proxy condition below. With custom hashers, confirm the flows cannot resolve to the same stored identifier.

Summary

Better Auth accepts an OAuth sign-in state value as a Magic Link token. An attacker can start an OAuth sign-in with any email address and use the returned state value to finish a Magic Link sign-in for that address. They never prove that they can read that email.

Impact

An unauthenticated attacker who knows a user's email address can get a normal session for that user, with all of that account's permissions. They need no access to the mailbox, and they do not need to sign in with the OAuth provider or complete an OAuth callback.

If Magic Link sign-up is enabled and no user has the email address, the attacker can instead create an account that Better Auth wrongly marks as email verified, and is signed in to it. The false verified-email flag can also bypass Better Auth's default unproven-account protections when the mailbox owner later signs in, leaving the attacker's session in that account. Applications that trust verified email addresses or email domains may give this account more access, depending on the application.

Patches

The fix is merged for the planned better-auth 1.7.7 release. That package is not available yet.

Upgrade better-auth and installed Better Auth adapters, plugins, and integrations released alongside it together. Upgrade servers sharing verification storage in the same cutover. Request new Magic Links and restart pending OAuth or SAML sign-ins; links and state created before the upgrade cannot complete. Update custom verification.storeIdentifier.overrides rules to match the magic-link: and auth-state: prefixes. There is no user or account data migration, and public token values and API signatures are unchanged.

Workarounds

Until you upgrade to the fixed release, use one of these mitigations:

  • Set account.storeStateStrategy to "cookie". If you also use OAuth Proxy, first configure one dedicated OAuth Proxy secret across all participating environments that differs from each environment's global Better Auth secret. Cookie-backed state can expose a separate OAuth Proxy account-takeover path when the proxy shares the global secret and implicit account linking is enabled.
  • Set the Magic Link plugin option storeToken to "hashed" and leave global verification.storeIdentifier unset or set it to "plain". Global identifier hashing does not prevent this issue when Magic Link tokens are also hashed.
  • Turn off Magic Link sign-in, or turn off social and Generic OAuth sign-in.

Setting disableSignUp: true on the Magic Link plugin is not enough. It only stops the creation of new verified accounts. It does not stop the takeover of existing accounts.

Severity

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

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

CVE-2026-67333 / 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 OSV and the GitHub Advisory Database (CC-BY 4.0).


Better Auth: Account takeover via pre-account hijacking on magic-link and email-OTP sign-in

CVE-2026-67327 / GHSA-qq9h-g4jm-xgf3

More information

Details

Am I affected

You are affected if all of the following hold:

  • You run a better-auth version below 1.6.22, or a 1.7.0-beta below 1.7.0-beta.10.
  • You enable the magic-link plugin or the email-OTP plugin.
  • You also enable email and password sign-up with open registration.
  • An account can exist at an address before its owner first signs in with the passwordless flow.
Summary

An attacker can keep password access to a victim's account after the victim starts using it. The attack runs in three steps. First, with open registration, the attacker signs up using the victim's email and a password the attacker picks. The account stays unverified, so the attacker cannot use it yet. Later the real owner signs in with a magic link or an email OTP. That step marks the account verified, and the attacker's password now works on it.

Details

When an account already exists for an address, magic-link verification and email-OTP sign-in both sign in to that account. They mark it verified and issue a session. Before the fix, neither one removed a password set while the account was still unverified. Neither one revoked existing sessions. So a password set before anyone proved control of the mailbox kept working after the owner proved control.

This is a pre-account-hijacking weakness. It is the same class as the OAuth advisory GHSA-g38m-r43w-p2q7, applied to the passwordless email flows. Requiring email verification does not stop it. The verification step is the exact moment that turns the planted account into a usable one.

The fix treats current proof of control over the address as authoritative. When either flow finds an account whose email was never confirmed, it now removes the password and revokes existing sessions first. Only then does it mark the account verified and issue the new session. The owner signs in as before. Only access created before the proof is removed.

Patches

Upgrade to better-auth 1.6.22 or later on the stable line. On the 1.7 pre-release line, upgrade to 1.7.0-beta.10 or later. Earlier 1.7 betas, up to and including 1.7.0-beta.9, still contain the bug.

Workarounds

If you cannot upgrade right away, you can lower the risk in two ways. Require email verification before you accept any password on an account. Remove unverified accounts quickly, so an address cannot sit reserved for long. Both steps shrink the opening, but they do not close it. Upgrading is the real fix.

Impact

The attacker gains lasting password access to an account the victim depends on, alongside the victim. From there the attacker can read and change the victim's data. The attacker can also reset the credentials and lock the victim out. The takeover only completes when the victim signs in through the passwordless flow. It also needs a setup that pairs that flow with open email and password sign-up.

Credit

Reported by the Vercel security team.

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 OSV and the GitHub Advisory Database (CC-BY 4.0).


better-auth: Device login approval does not show the requesting client or permissions

CVE-2026-109346 / GHSA-q84f-53jg-9ppm

More information

Details

Am I affected?

You may be affected if you use better-auth from 1.3.8-beta.3 up to, but not including, 1.7.0-rc.3, and you enable the deviceAuthorization() plugin. This range includes all 1.6 releases.

You are affected only if all of these are also true:

  • An attacker can start a device request: validateClient is not configured, or the attacker can use a valid public client.
  • Before allowing approval, your approval page does not show, from server-checked data, which client is asking and the exact permissions it requests.
  • Users may approve a code or complete verification link they did not start and cannot compare with a device they have.
  • Your application accepts the session token that the device flow returns, for example through the bearer plugin.

You are not affected if you do not enable the opt-in deviceAuthorization() plugin.

Summary

The device authorization plugin lets a user sign in on one device by approving a short code on another. In affected versions, the approval information given to the user leaves out which client asked for access and which permissions it wants. The official approval examples therefore show only a generic request and a code, so users cannot tell their own device from an attacker's request.

Impact

An attacker who convinces a signed-in user to approve the attacker's code receives a live Better Auth session token for that user. The attacker can then read or change any data the user's account can access. The attack needs the opt-in device flow, a request the attacker creates, and a user who actively approves it; it is not a sign-in bypass without user action.

Patches

The fix first shipped in 1.7.0-rc.3. Upgrade to better-auth@1.7.0 or later. No 1.6 release contains the fix.

After upgrading, GET /device returns the requesting client_id and scope to the signed-in account that owns the request. If you built your own approval page, update it to show that client and those permissions, and keep approval disabled until they load.

Workarounds

If you cannot upgrade yet, do the following:

  • Disable deviceAuthorization(), or block untrusted access to /device/code.
  • Configure a strict validateClient allowlist. Do not rely on the allowlist alone: it limits who can start the flow, but it does not give users the missing approval information.
  • Build your own approval endpoint. It should read the pending request from trusted server-side storage and map the checked client identifier to a trusted display name. Show the exact requested permissions and the user code. Keep approval disabled until this information loads.
  • Tell users to reject device requests they did not expect. They should approve only when the same code is visible right now on a device they have.
Technical details

A device starts the flow by calling /device/code. The plugin stores the client identifier and any requested scope with the pending request. Before approval, GET /device returns only the user code and its status. This is true even for a signed-in session of the user account that owns the request. An approval page built from that response cannot show which client asked for access or which permissions it wants. After approval, the original device code can be redeemed at /device/token for a Better Auth session token.

RFC 8628 describes this remote phishing scenario. It recommends that the server tell users they are authorizing a device and ask them to confirm that the device is in their possession. It also recommends showing device information that helps users detect impersonation. Client allowlisting reduces who can start the flow, but it does not give users the missing approval information. A valid public client can therefore still be affected when its identifier and requested permissions are not shown.

Severity

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

References

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


better-auth: OAuth state can be used as a Magic Link to sign in as another user

CVE-2026-110862 / CVE-2026-114607 / GHSA-965c-763c-88jm

More information

Details

Am I affected?

You may be affected if you use better-auth from 1.4.0-beta.18 through versions before 1.7.7 with the Magic Link plugin and social or Generic OAuth sign-in.

You are affected if all of these are true:

  • Magic Link is enabled with default storeToken: "plain", or with storeToken: "hashed" and global verification.storeIdentifier: "hashed" (including a hashed default).
  • At least one built-in social provider or Generic OAuth provider is enabled.
  • OAuth state uses account.storeStateStrategy: "database", the default when a database or secondary storage is configured.

Cookie-backed state blocks this path, subject to the OAuth Proxy condition below. With custom hashers, confirm the flows cannot resolve to the same stored identifier.

Summary

Better Auth accepts an OAuth sign-in state value as a Magic Link token. An attacker can start an OAuth sign-in with any email address and use the returned state value to finish a Magic Link sign-in for that address. They never prove that they can read that email.

Impact

An unauthenticated attacker who knows a user's email address can get a normal session for that user, with all of that account's permissions. They need no access to the mailbox, and they do not need to sign in with the OAuth provider or complete an OAuth callback.

If Magic Link sign-up is enabled and no user has the email address, the attacker can instead create an account that Better Auth wrongly marks as email verified, and is signed in to it. The false verified-email flag can also bypass Better Auth's default unproven-account protections when the mailbox owner later signs in, leaving the attacker's session in that account. Applications that trust verified email addresses or email domains may give this account more access, depending on the application.

Patches

The fix is merged for the planned better-auth 1.7.7 release. That package is not available yet.

Upgrade better-auth and installed Better Auth adapters, plugins, and integrations released alongside it together. Upgrade servers sharing verification storage in the same cutover. Request new Magic Links and restart pending OAuth or SAML sign-ins; links and state created before the upgrade cannot complete. Update custom verification.storeIdentifier.overrides rules to match the magic-link: and auth-state: prefixes. There is no user or account data migration, and public token values and API signatures are unchanged.

Workarounds

Until you upgrade to the fixed release, use one of these mitigations:

  • Set account.storeStateStrategy to "cookie". If you also use OAuth Proxy, first configure one dedicated OAuth Proxy secret across all participating environments that differs from each environment's global Better Auth secret. Cookie-backed state can expose a separate OAuth Proxy account-takeover path when the proxy shares the global secret and implicit account linking is enabled.
  • Set the Magic Link plugin option storeToken to "hashed" and leave global verification.storeIdentifier unset or set it to "plain". Global identifier hashing does not prevent this issue when Magic Link tokens are also hashed.
  • Turn off Magic Link sign-in, or turn off social and Generic OAuth sign-in.

Setting disableSignUp: true on the Magic Link plugin is not enough. It only stops the creation of new verified accounts. It does not stop the takeover of existing accounts.

Severity

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

References

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


better-auth: OAuth Proxy lets an attacker sign in as another user

CVE-2026-110864 / GHSA-r4xp-prcw-77qf

More information

Details

Am I affected?

You may be affected if you use better-auth from 1.5.0-beta.12 through versions before 1.7.7 with the OAuth Proxy plugin. You are affected only if all of these are also true:

  • On a participating environment, the OAuth Proxy plugin has no dedicated secret, or its secret equals that environment's global Better Auth secret.
  • Sign-in state is stored in a cookie (account.storeStateStrategy: "cookie"). With a database or secondary storage, this is only the case if you chose it explicitly.
  • Implicit account linking is on (the default).

You are not affected by this path if every participating environment uses a proxy secret distinct from its global Better Auth secret, if sign-in state is stored in the database, or if you use a version before 1.5.0-beta.12.

Summary

The OAuth Proxy plugin trusts any user profile it can decrypt. In the affected setup, Better Auth encrypts sign-in state, including data the caller chose, with the same secret and hands it to anyone who starts a social sign-in. An attacker can use this to get a fake profile for another user's email address that the proxy accepts as real.

Impact

An attacker without an account who knows a user's verified email address can sign in as that user, with that user's permissions. They can read private data, change the account, and link a provider account of their choice to it. They need no provider sign-in and no action from the victim. No availability impact is established, and a reported open redirect was not confirmed.

Patches

The fix is merged for the planned better-auth 1.7.7 release. That package is not available yet.

Upgrade all nodes handling the same cookie-backed OAuth or SAML state flow and all OAuth Proxy participants together, including the production coordinator and preview or development deployments. Keep installed Better Auth packages released alongside better-auth on the same release version. Restart pending sign-in and account-linking flows; pre-upgrade state cookies and proxy payloads are rejected. Public options and API signatures are unchanged.

Workarounds

Until you upgrade to the fixed release, apply one of these:

  • Set one strong OAuth Proxy secret across every environment that takes part in the proxy flow, and keep it different from each environment's global Better Auth secret.
  • Set account.storeStateStrategy: "database". This is already the default when a database or secondary storage is configured. If Magic Link is also enabled, switching can expose a separate cross-purpose verification-record path in affected releases. Ensure the two flows resolve to distinct stored identifiers; with built-in options, use Magic Link storeToken: "hashed" with global verification.storeIdentifier unset or "plain", or disable Magic Link or both built-in social and Generic OAuth sign-in.
  • Remove the OAuth Proxy plugin if you do not need it.

Setting account.accountLinking.disableImplicitLinking: true stops this path from taking over an existing account. It does not fix the underlying flaw: the callback can still accept attacker-made data as a provider identity. Apply one of the steps above as well.

Severity

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

References

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


Release Notes

better-auth/better-auth (better-auth)

v1.7.7

Compare Source

Patch Changes

[!IMPORTANT]
✂ PR body was truncated to here.

@renovate renovate Bot added the security label Oct 22, 2026
@pacphi

pacphi commented Oct 22, 2026 •

Copy link
Copy Markdown
Owner

✅ Snyk checks have passed. No issues have been found so far.

Status Scan Engine Critical High Medium Low Total (0)
✅ Open Source Security 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

@renovate
renovate Bot force-pushed the renovate/npm-better-auth-vulnerability branch from df4c480 to 4a14471 Compare October 30, 2026 07:17
@renovate renovate Bot changed the title chore(deps): update dependency better-auth to v1.6.22 [security] chore(deps): update dependency better-auth to v1.7.7 [security] Oct 30, 2026
@renovate
renovate Bot force-pushed the renovate/npm-better-auth-vulnerability branch from 4a14471 to 7c9b448 Compare November 4, 2026 05:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant