Better Auth has stored XSS in the auth-server origin via javascript: redirect_uri in oidc-provider and mcp
Check each condition. Users are affected when all of the first three hold.
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.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.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:
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.A developer's application is not affected when any of these hold:
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.javascript: URL delivered in a Location response header.@better-auth/oauth-provider instead of the deprecated oidc-provider plugin.Fix:
better-auth@1.6.13 (stable) or 1.7.0-beta.4 (pre-release).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.
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.
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.
If developers cannot upgrade, apply one of the following.
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.@better-auth/oauth-provider. It validates redirect URIs at registration and is the supported replacement for the deprecated plugin.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.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.
Reported by @hillalee
window.location to a javascript: URL executes the script body. https://developer.mozilla.org/en-US/docs/Web/URI/Reference/Schemes/javascript왜 이 VPI인가 (설명가능 · 실험적)
VPI 산정 기준
| 영향도 | 77.00 |
| 악용 신호(추가 악용신호 없음) | ×1.00 |
| VPI | 77.00 |
VPI 공식 vpi-v1 기준