Where
-Infinity
0
Severity
7.6
Race Condition
AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:N

Am I affected?

Users are affected if all of the following are true:

- Their project depends on @better-auth/oauth-provider at a version >= 1.6.0, < 1.6.11, or uses the embedded plugin in better-auth >= 1.4.8-beta.7, < 1.6.0, or enables the legacy oidc-provider or mcp plugins from better-auth/plugins. - Their application exposes /api/auth/oauth2/token (or the legacy plugins' /oauth2/token and /mcp/token) as a token endpoint to OAuth/OIDC clients, including internal MCP clients (Claude Desktop, custom MCP tool callers, AI agents). - Their application has not implemented an external mitigation: a load-balancer-level idempotency cache keyed by code, a database trigger that rejects duplicate token issuance for the same authorization code, or a custom adapter override that performs an atomic compare-and-delete.

Fix:

1. Upgrade to @better-auth/oauth-provider@1.6.11 or later. If developers use the legacy plugin paths from better-auth/plugins, upgrade better-auth to 1.6.11 or later. 2. If developers cannot upgrade, see workarounds below.

Summary

The OAuth provider's POST /oauth2/token endpoint, on the authorizationcode grant, redeems a single-use authorization code through a non-atomic find-then-delete sequence. Two concurrent requests with the same code value both pass the read step before either delete completes, then both proceed to PKCE verification and createUserTokens. Each surviving request mints a fresh access token, refresh token, and id token. RFC 6749 §4.1.2 requires authorization codes to be single-use; this primitive does not enforce that under concurrency.

Details

The same architectural primitive (find a single-use verification row, then delete it, then trust the row to authorize) is used in 20 other call sites across the codebase. The deletion primitive returns Promise<void>, discarding the row count surfaced by adapter.deleteMany, so no call site can detect "another caller already claimed this row". The fix lands at the primitive layer rather than at any individual call site.

The fix introduces a claimVerificationByIdentifier primitive at the internal-adapter layer that performs an atomic claim-and-return, replaces the find-then-delete pair at this call site, and migrates the highest-impact variant sites in the same release.

Patches

Fixed in @better-auth/oauth-provider@1.6.11 and better-auth@1.6.11 for the legacy oidc-provider and mcp plugin paths. All three token-exchange call sites now consume the verification row through internalAdapter.consumeVerificationValue, an atomic claim primitive that deletes the row and returns its prior value in one operation. The first request to arrive takes the row and mints tokens; concurrent racers observe an empty result and return invalidgrant.

Error-code consistency is also tightened on the @better-auth/oauth-provider token endpoint: the malformed-verification-value branches previously returned a project-specific invalidverification code, which is not part of RFC 6749 §5.2's response error set. Both branches now return invalidgrant so spec-compliant clients can branch on the standard code without a special case.

Workarounds

None of these close the bug fully without a code patch. Upgrading is the only good path.

- Network-layer: deploy an authorization-server-aware reverse proxy (Envoy, NGINX with Lua, custom Cloudflare Worker) that holds an in-flight registry keyed by the code parameter and serializes concurrent requests for the same code. Fragile under multi-instance deployments unless the registry is shared (Redis-backed). - Database-layer: add a SQL or Mongo uniqueness constraint that prevents two oauthAccessToken rows from being created with the same upstream code reference. Adapter-specific and not always feasible since the schema does not currently store the source code. - Application-layer: wrap deleteVerificationByIdentifier with a custom hook that uses adapter.deleteMany and surfaces the count, then injects an invalidgrant rejection when the count is zero. Requires forking the internal adapter.

Impact

- Multiple independent token sets from a single authorization: forked access tokens, refresh tokens, and id tokens issued from the same code, all valid for the original user's authorization scope. - Detection bypass: standard OAuth single-use enforcement does not fire for the second redemption when both requests interleave through the read step. - Legacy-plugin reach: oidc-provider and mcp plugins share the primitive on the same surface, so deployments using them inherit the same impact.

Credit

Reported by @chdanielmueller.

Resources

- CWE-362: Concurrent Execution using Shared Resource with Improper Synchronization (Race Condition) - CWE-367: Time-of-check Time-of-use (TOCTOU) Race Condition - CWE-294: Authentication Bypass by Capture-replay - RFC 6749 §4.1.2: Authorization Response - OAuth 2.1 §4.1: Authorization Code Grant

1 / 2
Source: GitHub
First published (updated )
Severity
8.1
Race Condition
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N

Am I affected?

Users are affected if all of the following are true:

- Their project depends on @better-auth/oauth-provider at a version >= 1.6.0, < 1.6.11, or uses the embedded plugin in better-auth >= 1.4.8-beta.7, < 1.6.0. - At least one OAuth client served by their application's authorization server requests the offlineaccess scope, so refresh tokens are minted. - Concurrent redemption of the same refresh token is reachable: an SPA shares one refresh token across browser tabs without a mutex, a mobile client retries after a transient failure, an attacker who has stolen a refresh token times two requests, or a service worker queues offline requests.

If developer applications do not request offlineaccess for any client, no refresh tokens are minted and they are not exposed.

Fix:

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

Summary

The OAuth provider's POST /oauth2/token endpoint, on the refreshtoken grant, performs a non-atomic read / validate / revoke / mint sequence on the oauthRefreshToken row. Two concurrent requests presenting the same parent refresh token both pass the revocation check before either revoke completes, so each mints a fresh refresh token. The replay-detection branch only fires when revoked is already truthy at read time, which is exactly the state concurrent attackers race past. The result is a forked refresh-token family from a single parent token.

Details

The adapter.update predicate on the parent row is keyed on id only; it does not include revoked IS NULL, so two concurrent updates both succeed (last-write-wins, no error path). The schema does not declare unique on oauthRefreshToken.token, so concurrent creates do not collide on a unique-key violation either.

RFC 9700 §4.14 (OAuth Security Best Current Practice) prescribes refresh-token family invalidation on detected reuse; this implementation tries to enforce that contract through the revoked check, but the check is not atomic with the consumption step. Token rotation issues a new refresh token with each call, so a single stolen refresh token grants indefinite access until the row is revoked or its refreshTokenExpiresAt (default 7 days) passes. Rotation refreshes that window each call.

The fix lands an atomic compare-and-swap on the parent row inside the rotation primitive (UPDATE ... WHERE id = ? AND revoked IS NULL with a rowcount check), so the losing rotation fails closed with invalidgrant and the parent row stays marked revoked. Subsequent replay of the original refresh token then trips the existing family-invalidation guard. The schema gains a unique constraint on oauthRefreshToken.token for parity with oauthAccessToken.token.

Patches

Fixed in @better-auth/oauth-provider@1.6.11. The refresh-token rotation primitive now performs an atomic compare-and-swap on the parent row, and the explicit revokeRefreshToken path uses the same CAS. On a contested rotation, exactly one caller wins and mints a fresh refresh token; the loser receives invalidgrant. Subsequent replay of the original refresh token trips the existing family-invalidation guard because the parent row stays marked revoked.

@better-auth/memory-adapter@1.6.11 ships a compatibility fix in the same wave: the in-memory where clause now treats undefined and null as equivalent under an eq null predicate, mirroring SQL IS NULL and Mongo's missing-or-null semantics. Without this change, the CAS predicate WHERE revoked IS NULL falls through on every call against a row whose optional revoked field is absent (the adapter factory's transformInput skips writing undefined when no default exists), so the rotation above is broken for any deployment using the in-memory adapter.

Strict refresh-token family invalidation on a contested rotation, per RFC 9700 §4.14 (which calls for invalidating the winner's tokens too when reuse is detected at rotation time), is deferred to a follow-up minor on the next channel. Closing it cleanly requires an opt-in transactional rotation in the adapter contract so the family-delete cannot interleave with the winner's in-flight access-token insert. The deferred site carries a FIXME(strict-family-invalidation) marker.

Schema-migration note: the better-auth migration generator only emits UNIQUE for newly-created columns. Existing installs will not pick up the new oauthRefreshToken.token unique constraint from migrate / generate; add it manually if an application's operational tooling depends on it (CREATE UNIQUE INDEX oauthrefreshtokentokenuniq ON "oauthRefreshToken" (token);). The CAS fix above does not depend on the database-level constraint to be correct; the constraint is defense-in-depth so collisions from a buggy custom generateRefreshToken callback fail loudly.

Workarounds

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

- Adapter-level: configure the database adapter to run the OAuth refresh handler under serializable isolation, or wrap the adapter.update on oauthRefreshToken with a row-level pessimistic lock (SELECT ... FOR UPDATE). Narrows the window without closing it. - Token lifetime: pass oauthProvider({ refreshTokenExpiresIn: 60 }) to expire forked families within one minute. Trades attacker persistence for shorter user sessions. - Client-side single-flight: serialize refresh-token usage in the client SDK with a mutex. Mitigates honest concurrency but does nothing against an attacker with a stolen refresh token. - Disable refresh tokens: do not request the offlineaccess scope. Closes the surface but breaks long-lived sessions.

Impact

- Indefinite access from a single stolen refresh token: forked refresh-token families grant access at the original user's authorization scope, surviving past any single revocation if an attacker holds any branch. - Detection bypass: legitimate users whose refresh token has been forked do not trip family invalidation when they refresh, because the attacker's branch already swapped the parent row out from under the legitimate user's check.

Credit

Reported by @chdanielmueller.

Resources

- CWE-362: Concurrent Execution using Shared Resource with Improper Synchronization (Race Condition) - CWE-367: Time-of-check Time-of-use (TOCTOU) Race Condition - CWE-294: Authentication Bypass by Capture-replay - CWE-613: Insufficient Session Expiration - RFC 9700 §4.14: Refresh Token Protection - RFC 6749 §6: Refreshing an Access Token

1 / 2
Source: GitHub
First published (updated )
Severity
9.6
Input Validation, SSRF
AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:N

Am I affected?

Users are affected if all of the following are true:

- Their application uses @better-auth/sso at a version >= 0.1.0, < 1.6.11 on the stable line, or any 1.7.0-beta.x on the pre-release line. - The sso() plugin is added to their application's betterAuth({ plugins: [...] }) array. - Any user with a valid Better Auth session can reach POST /sso/register (the plugin's default gate accepts any session).

For the non-blind SSRF impact (full IAM credential or internal HTTP body exfiltration), no further configuration is required.

For the account takeover escalation, additionally:

- Developers set sso({ trustEmailVerified: true, ... }). - The developer's application deployment has accounts whose email overlaps with attacker-chosen domains.

If developers do not enable the SSO plugin, their application is not affected.

Fix:

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

Summary

The @better-auth/sso plugin's POST /sso/register endpoint accepts attacker-controlled oidcConfig.userInfoEndpoint, tokenEndpoint, and jwksEndpoint URLs when skipDiscovery: true is set, persists them on the ssoProvider row without origin validation, then issues server-side fetches to those URLs during the OIDC callback. The fetched response body is reflected through the user profile, producing a non-blind SSRF reachable by any authenticated session. The same primitive exists on POST /sso/update-provider.

Details

The schema field types accept bare strings: no .url() validator, no origin gate. The discovery branch (skipDiscovery: false) routes URLs through validateDiscoveryUrl; the skip-discovery branch persists them as-is. At callback time three fetch sites read the stored URLs: validateAuthorizationCode for the token endpoint, betterFetch for the userInfo endpoint, and validateToken for the JWKS endpoint.

When trustEmailVerified: true is configured, the attacker can escalate to account linking. A malicious userInfo response with emailVerified: true and a chosen email triggers OAuth auto-link against any pre-existing user row with that email, compounding the SSRF into account takeover.

Patches

Fixed in @better-auth/sso@1.6.11. Provider registration (POST /sso/register with skipDiscovery: true) and every POST /sso/update-provider request now validate each supplied OIDC endpoint URL (authorizationEndpoint, tokenEndpoint, userInfoEndpoint, jwksEndpoint, discoveryEndpoint) at registration time. A URL is rejected unless it satisfies one of two conditions:

1. Its host is publicly routable on the internet, evaluated through the @better-auth/core/utils/host.isPublicRoutableHost gate. RFC 1918 private ranges, RFC 4193 unique-local addresses, link-local addresses (including the cloud-metadata IP 169.254.169.254), loopback, multicast, broadcast, and reserved ranges are rejected, along with cloud-metadata FQDNs. 2. Its origin is already listed in the application's trustedOrigins configuration. This preserves the documented escape hatch for customers running internal IdPs intentionally on private networks.

The schema also tightens from z.string() to z.url() on those fields, so malformed URLs fail at parse time rather than at fetch time. Deployments running internal IdPs that previously worked must add the IdP's origin to trustedOrigins to keep working after upgrade.

Workarounds

If developers cannot upgrade immediately:

- Disable provider self-registration: set sso({ providersLimit: 0 }). The limit is enforced before the schema branch, blocking every /sso/register regardless of skipDiscovery. - Reverse-proxy gate: block POST /sso/register and POST /sso/update-provider at the edge, or restrict to a denylist of source IPs and a small admin user list. - Network-level egress controls: block egress from the auth server to RFC 1918, RFC 4193, link-local ranges (169.254.0.0/16, fe80::/10), and the cloud-metadata FQDN list at the firewall or VPC level. AWS users should additionally enforce IMDSv2 (HttpTokens: required). - Set trustEmailVerified: false until upgrade. This caps the impact at non-blind SSRF and removes the account-takeover escalation, but does not stop the SSRF.

Impact

- Server-Side Request Forgery (non-blind): the attacker reads response bodies from any HTTP endpoint reachable from the auth server, including cloud metadata services (AWS IMDS, GCP metadata FQDN), internal-only APIs, and infrastructure services such as Redis or admin panels bound to localhost. - Account takeover (when trustEmailVerified: true): the attacker mints a malicious userInfo response asserting emailVerified: true for an arbitrary email, triggering OAuth auto-link against pre-existing user rows.

Credit

Reported by Vaadata.

Resources

- CWE-918: Server-Side Request Forgery (SSRF) - CWE-20: Improper Input Validation - CWE-441: Unintended Proxy or Intermediary - CWE-345: Insufficient Verification of Data Authenticity

1 / 2
Source: GitHub
First published (updated )
Severity
7.1
AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:H/A:N

Am I affected?

You are affected if all of the following are true:

- You depend on @better-auth/sso at any version in >= 1.2.10, < 1.6.11, or any current next pre-release. - You enable both sso() and organization() plugins. - providersLimit is at its default (10) or any non-zero value, so SSO provider registration is enabled for authenticated users. - An organization has non-admin members, or your application can add users to organizations as regular members.

You are at the highest risk if any of these also hold:

- Organization membership can be obtained without a direct admin decision, such as through open invitations, self-serve onboarding, public team joins, or SCIM bulk imports. - organizationProvisioning.defaultRole or organizationProvisioning.getRole returns admin or higher for SSO-provisioned users. The bug then becomes unauthorized admin creation in the org. - domainVerification.enabled is false (the default). The malicious provider is immediately usable.

Fix:

1. Upgrade to @better-auth/sso@1.6.11 or later. 2. If you cannot upgrade, see workarounds below.

Summary

The SSO plugin's POST /sso/register endpoint lets any member of an organization attach a new SSO provider to that organization. It checks that the caller has a membership row, but it does not check whether the caller has an administrative role for the organization.

This creates an authorization mismatch for the same resource. Other org-linked SSO provider management endpoints treat those providers as admin-managed: list, get, update, and delete require the caller to be an organization owner or admin. The create path is less restrictive, so a regular member can attach an attacker-controlled OIDC or SAML identity provider to an organization they do not administer. After registration, downstream organization provisioning can add IdP-asserted users from /sso/callback/{providerId} into the target organization, defaulting to role member.

Details

This issue does not rely on a separate documentation statement that only admins may create SSO connections. The issue is that Better Auth already enforces an admin boundary for org-linked SSO provider management, but registerSSOProvider does not enforce the same boundary when the provider is first created.

The list, get, update, and delete endpoints in providers.ts gate org-linked SSO providers via isOrgAdmin, which accepts owner or admin. The create path in sso.ts performs only a membership lookup and never inspects member.role. As a result, the endpoint allows a low-privilege organization member to create a provider record that they would not be allowed to view, update, or delete through the companion provider-management endpoints.

The fix introduces a shared hasOrgAdminRole(member) helper (refactored out of isOrgAdmin) and adds the admin check to the registration handler so that registration matches the protections on the read and mutation paths.

Patches

Fixed in @better-auth/sso@1.6.11. When organizationId is supplied and the organization plugin is enabled, the registerSSOProvider handler now requires the caller to hold the owner or admin role on the target organization. This makes provider creation match the existing protection on the get, update, and delete endpoints.

Workarounds

If you cannot upgrade immediately:

- Disable user-driven SSO registration entirely: set sso({ providersLimit: 0 }). Registration throws FORBIDDEN before the membership gate. Trade-off: admins also lose self-serve provider creation; provisioning has to go through server-side auth.api.registerSSOProvider({ headers: serverAdminHeaders, body }) calls. - Disable SSO-driven org provisioning: set sso({ organizationProvisioning: { disabled: true } }). The malicious provider can still be registered, but the SSO callback no longer adds users to the org automatically. Trade-off: legitimate SSO-driven onboarding stops. - Custom before hook on /sso/register that asserts the caller's role on body.organizationId is owner or admin. This duplicates the patch shape in user code. - Audit existing rows: list ssoProvider rows where organizationId IS NOT NULL, cross-reference each userId with member.role for that organization, and remove provider rows whose creator is not currently owner or admin. Removing the provider does not auto-remove members it created, so cleanup is two-step.

Impact

- Unauthorized provider configuration within an organization tenant: a regular member writes an SSO provider record on an organization they do not administer. - Unauthorized organization membership creation: subsequent SSO callbacks can add IdP-asserted users to the target organization at the configured default role. - Admin creation when configured: if organizationProvisioning.defaultRole is admin, or organizationProvisioning.getRole returns admin, the issue can create admin-grade users in the target organization without owner consent.

Credit

Reported by @Nadav0077. The same finding was previously raised in public issue #9133 (2026-04-12).

1 / 2
Source: GitHub
First published (updated )
Severity
8.3
AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:L

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 emailverified: 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 emailverified 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

- CWE-287: Improper Authentication - CWE-345: Insufficient Verification of Data Authenticity - Sudhodanan & Paverd, Pre-hijacked accounts: an empirical study of security failures in user account creation on the web (USENIX Security 2022)

1 / 2
Source: GitHub
First published (updated )
Severity
7.7
AV:N/AC:H/PR:L/UI:R/S:C/C:H/I:H/A:N

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 EMAILVERIFICATIONREQUIREDFORINVITATION error code so the wording matches the operation; acceptInvitation and rejectInvitation keep the existing EMAILVERIFICATIONREQUIREDBEFOREACCEPTINGORREJECTINGINVITATION 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

- CWE-287: Improper Authentication - CWE-345: Insufficient Verification of Data Authenticity - CWE-862: Missing Authorization - CWE-441: Unintended Proxy or Intermediary

1 / 2
Source: GitHub
First published (updated )
Severity
9.1
XSS
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N

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 refreshtoken grant authenticates the request entirely on possession of the bound refreshToken row and a matching clientid. Neither plugin verifies the registered confidential client's clientsecret on the refresh path. An attacker who obtains any valid refreshtoken (via database read, log capture, browser-side XSS, or CORS-amplified script in the mcp case) and the public clientid 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' authorizationcode grant correctly enforces clientsecret (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 refreshtoken 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 authorizationcode 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 clientsecret on the refreshtoken grant for confidential clients, using the same constant-time comparison the authorizationcode 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 clientsecret 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 clientsecret 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 refreshtoken and the public clientid 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

- CWE-306: Missing Authentication for Critical Function - CWE-287: Improper Authentication - CWE-345: Insufficient Verification of Data Authenticity - CWE-863: Incorrect Authorization - RFC 6749 §6: Refreshing an Access Token - OAuth 2.1 §4.3: Refresh Token

1 / 2
Source: GitHub
First published (updated )
Severity
7.6
AV:N/AC:L/PR:L/UI:R/S:U/C:H/I:H/A:L

Am I affected?

You are affected if all of the following are true:

- You use better-auth at a version >= 1.6.0, < 1.6.11. - The deviceAuthorization plugin is enabled in your auth config (deviceAuthorization() in your plugins array). - A third party can observe a pending user code before the legitimate user completes verification.

The standard device-flow UX displays user codes to humans, so realistic exposure includes shoulder-surfing, screen-share, voice or video calls, support-chat transcripts, referrer headers, and shared logs.

If your application does not enable the deviceAuthorization plugin, you are not affected.

Fix:

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

Summary

Better Auth's deviceAuthorization plugin treated any authenticated session as the owner of any pending device code. The ownership gate on POST /device/approve and POST /device/deny short-circuited whenever the row's userId was unset, and the GET /device verification handler did not claim the row. An authenticated attacker who learned a valid usercode before the legitimate user completed approval could bind the polling device to the attacker's account or deny the legitimate flow.

Details

The device authorization flow binds the polling device to the user who entered the user code on the verification page. In affected versions, the plugin only created that binding at approve or deny time, with no claim at the verification step. The ownership check at approve and deny short-circuited when the owner was missing, accepting any authenticated caller instead of rejecting the request.

The fix changes GET /device to claim the pending row for the calling session. The approve and deny gates now require strict equality between the row's owner and the calling session. RFC 8628 §5.5 covers this risk class as Session Spying: a malicious party can hijack a session by completing authorization before the legitimate initiating user does.

Patches

Fixed in better-auth@1.6.11. After the patch, GET /device claims the pending row for the calling session, and POST /device/approve and POST /device/deny reject calls whose session does not match the claimed owner. Custom verification pages must serve GET /device to an authenticated session for the flow to succeed.

Workarounds

If you cannot upgrade immediately:

- Disable the plugin if you do not use the device flow: remove deviceAuthorization() from your plugins array. - Add a before hook on POST /device/approve and POST /device/deny that tracks which session called GET /device for each user code, and rejects calls from a different session. - Shorten the pending lifetime of device codes via the expiresIn plugin option to reduce the exploitation window.

Impact

- Account takeover on the polling device: the attacker's session becomes the device's session, so the device operates as the attacker. - Denial of the legitimate sign-in: the attacker can mark the code as denied, blocking the victim's flow.

Credit

Reported by Quikturn Security Team.

1 / 3
Source: GitHub
First published (updated )
Severity
6.9
EPSS
0.09%
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:N/SC:L/SI:L/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

Summary The application is vulnerable to an open redirect due to improper validation of the callbackURL parameter in the email verification endpoint and any other endpoint that accepts callback url. While the server blocks fully qualified URLs (e.g., https://evil.com), it incorrectly allows scheme-less URLs (e.g., //malicious-site.com). This results in the browser interpreting the URL as https://malicious-site.com, leading to unintended redirection.

bypass for : https://github.com/better-auth/better-auth/security/advisories/GHSA-8jhw-6pjj-8723

Affected Versions All versions prior to 1.1.19

Details The application’s email verification endpoint (/auth/verify-email) accepts a callbackURL parameter intended to redirect users after successful email verification. While the server correctly blocks fully qualified external URLs (e.g., https://evil.com), it improperly allows scheme-less URLs (e.g., //malicious-site.com). This issue occurs because browsers interpret //malicious-site.com as https://malicious-site.com, leading to an open redirect vulnerability.

An attacker can exploit this flaw by crafting a malicious verification link and tricking users into clicking it. Upon successful email verification, the user will be automatically redirected to the attacker's website, which can be used for phishing, malware distribution, or stealing sensitive authentication tokens.

Impact Phishing & Credential Theft – Attackers can redirect users to a fake login page, tricking them into entering sensitive credentials, which can then be stolen.

Session Hijacking & Token Theft – If used in OAuth flows, an attacker could redirect authentication tokens to their own domain, leading to account takeover.

1 / 2
Source: GitHub
First published (updated )

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203