GHSA-86j7-9j95-vpqjHighCVSS 7.7Disclosed before NVD

Better Auth has stored XSS in the auth-server origin via javascript: redirect_uri in oidc-provider and mcp

Published
July 7, 2026
Last Modified
July 7, 2026

📋 Description

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 [email protected] (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 [email protected] (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

  • CWE-79: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting'). https://cwe.mitre.org/data/definitions/79.html
  • CWE-601: URL Redirection to Untrusted Site ('Open Redirect'). https://cwe.mitre.org/data/definitions/601.html
  • RFC 6749 (OAuth 2.0) Section 3.1.2, Redirection Endpoint: the redirection endpoint URI must be an absolute URI. https://datatracker.ietf.org/doc/html/rfc6749#section-3.1.2
  • MDN, javascript: URLs: navigating window.location to a javascript: URL executes the script body. https://developer.mozilla.org/en-US/docs/Web/URI/Reference/Schemes/javascript

🎯 Affected products2

  • npm/better-auth:< 1.6.13
  • npm/better-auth:>= 1.7.0-beta.0, < 1.7.0-beta.4

🔗 References (4)