Skip to content

CVE-2026-53513: Verified Repro With Script Download

CVE-2026-53513: @better-auth/sso <1.6.11 allows non-blind SSRF via unvalidated OIDC endpoint URLs during SSO provider registration, with potential account takeover when trustEmailVerified is enabled.

CVE-2026-53513 is verified against @better-auth/sso · npm. Affected versions: >=0.1.0, <1.6.11 (also 1.7.0-beta.x). Fixed in 1.6.11. Vulnerability class: SSRF. This critical reproduction includes runnable sandbox proof, artifacts, and a plain-text agent view under REPRO-2026-00274.

REPRO-2026-00274 @better-auth/sso · npm SSRF Variant found Jul 8, 2026 CVE entry ↗ .txt
Severity
CRITICAL
CVSS
9.6
Confidence
HIGH
Reproduced in
30m 49s
Tool calls
281
Spend
$5.19
01 · Overview

What Is CVE-2026-53513?

CVE-2026-53513 is a critical (CVSS 9.6) SSRF vulnerability in the @better-auth/sso plugin for better-auth, where unvalidated OIDC endpoint URLs supplied during SSO provider registration are fetched server-side, and if trustEmailVerified is enabled, can lead to account takeover. Pruva reproduced it (reproduction REPRO-2026-00274).

02 · Severity & CVSS

CVE-2026-53513 Severity & CVSS Score

CVE-2026-53513 is rated critical severity, with a CVSS base score of 9.6 out of 10.

CRITICAL threat level
9.6 / 10 CVSS base
Weakness CWE-20 — Improper Input Validation

Critical — the most severe class — typically remotely exploitable with severe impact. Treat as an emergency.

03 · Affected Versions

Affected @better-auth/sso Versions

@better-auth/sso · npm versions >=0.1.0, <1.6.11 (also 1.7.0-beta.x) are affected.

How to Reproduce CVE-2026-53513

$ pruva-verify REPRO-2026-00274
or curl -O https://pruva.dev/api/v1/reproductions/REPRO-2026-00274/artifacts/bundle/repro/reproduction_steps.sh && chmod +x reproduction_steps.sh && ./reproduction_steps.sh
Run in a VM or disposable container. This exploits a real vulnerability.
06 · Proof of Reproduction

Proof of Reproduction for CVE-2026-53513

Authorization bypass — reproduced
  • reached the target end-to-end
  • full exploit chain demonstrated
  • on the real production code path
  • high confidence
  • the upstream fix blocks the same trigger
Trigger

attacker-controlled OIDC endpoint URLs (authorizationEndpoint, tokenEndpoint, userInfoEndpoint, jwksEndpoint, discoveryEndpoint) in POST /sso/register with skipDiscovery:true

Attack chain
  1. POST /api/auth/sso/register
  2. stored OIDC config
  3. POST /api/auth/sign-in/sso
  4. /api/auth/sso/callback/:providerId
  5. server-side fetch to tokenEndpoint and userInfoEndpoint
Variants tested

The 1.6.11 fix for @better-auth/sso SSRF validates only the literal hostname of user-supplied OIDC endpoints. A public-looking FQDN that resolves to a private IP (e.g., an attacker-controlled public domain or container hostname) is accepted and then fetched server-side during the OIDC callback, reproducing the SSRF on…

How the agent worked 749 events · 281 tool calls · 31 min
31 minDuration
281Tool calls
227Reasoning steps
749Events
7Dead-ends
Agent activity over 31 min
Support
13
Hypothesis
2
Repro
302
Judge
21
Variant
407
0:0030:49

Root Cause and Exploit Chain for CVE-2026-53513

Versions: >= 0.1.0, < 1.6.11 (also affects 1.7.0-beta.x pre-releases)

@better-auth/sso versions < 1.6.11 allow an authenticated attacker to register or update an OIDC SSO provider with arbitrary attacker-controlled endpoint URLs when skipDiscovery: true is used. The supplied authorizationEndpoint, tokenEndpoint, userInfoEndpoint, jwksEndpoint, and discoveryEndpoint values are persisted without validating that the host is publicly routable. During the OIDC callback flow the server makes server-side HTTP requests to those stored URLs, enabling non-blind SSRF. When the plugin is configured with trustEmailVerified: true, a malicious userInfo response can claim emailVerified: true for any existing email, causing the attacker to be linked to (and receive a session for) the matching victim account.

  • Package/component affected: npm:@better-auth/sso (better-auth monorepo packages/sso)
  • Affected versions: >= 0.1.0, < 1.6.11 (also affects 1.7.0-beta.x pre-releases)
  • Patched version: 1.6.11
  • Risk level: Critical
  • Consequences:
    1. Server-Side Request Forgery (SSRF) – any authenticated user can force the better-auth server to fetch arbitrary internal/private URLs during the OIDC callback.
    2. Account Takeover (ATO) – when trustEmailVerified: true is enabled, a forged userInfo payload can claim an arbitrary email is verified, causing the attacker to be logged in as the existing user with that email.

Impact Parity

  • Disclosed/claimed maximum impact: SSRF with possible account takeover when trustEmailVerified: true.
  • Reproduced impact from this run: Full SSRF and account takeover were reproduced. The attacker-registered provider caused the server to hit http://127.0.0.1:9876/token and http://127.0.0.1:9876/userinfo, and the callback produced a session whose user.email was victim@example.com.
  • Parity: full
  • Not demonstrated: None. The reproduction reaches the disclosed endpoint surface and matches the claimed impact.

Root Cause

The POST /sso/register endpoint (and POST /sso/update-provider) has a skipDiscovery: true branch that serializes the user-supplied oidcConfig object directly into the provider row without checking URL origins or host reachability. In packages/sso/src/routes/sso.ts (vulnerable 1.6.10):

if (body.oidcConfig.skipDiscovery) {
  return JSON.stringify({
    ...body.oidcConfig,
    issuer: body.issuer,
  });
}

Later, the OIDC callback (/sso/callback/:providerId) uses the stored config.tokenEndpoint with validateAuthorizationCode() and config.userInfoEndpoint with betterFetch(), sending real HTTP requests to those URLs. Because the callback runs server-side, any URL (loopback, RFC 1918, cloud metadata) is reachable.

The trustEmailVerified: true option in sso({ trustEmailVerified: true }) causes the callback to accept the email_verified claim from the attacker-controlled userInfo response, which then matches an existing user by email and links the attacker to that account via the OAuth auto-linking flow.

Fix

The fix was introduced in PR #9574 / commit 37f60cb176cb53147da7dfd5ec15afa5b486e81e and released in @better-auth/sso@1.6.11. It adds a layered gate for all user-supplied OIDC URLs:

  1. URL parsing + http(s) scheme requirement.
  2. isPublicRoutableHost from @better-auth/core/utils/host (rejects loopback, RFC 1918, link-local, cloud-metadata FQDNs, etc.).
  3. trustedOrigins allowlist as an escape hatch for private/internal IdPs.

In our reproduction the fixed version rejects the malicious registration with BAD_REQUEST and the discovery_private_host error code for http://127.0.0.1:9876/authorize.

Reproduction Steps

  1. Reference script: bundle/repro/reproduction_steps.sh
  2. What the script does:
    • Creates two isolated Node.js workspaces in the durable project cache, one using @better-auth/sso@1.6.10 (vulnerable) and one using @better-auth/sso@1.6.11 (fixed).
    • In each workspace it writes a Vitest test that:
      • Starts a real better-auth HTTP server on 127.0.0.1:3000 using the sso({ trustEmailVerified: true }) plugin.
      • Starts an attacker-controlled HTTP server on 127.0.0.1:9876 with /authorize, /token, /userinfo, and /jwks endpoints.
      • Creates a victim user (victim@example.com).
      • Signs in as the attacker test user.
      • Sends POST /api/auth/sso/register with skipDiscovery: true and attacker-controlled OIDC endpoints.
      • Initiates POST /api/auth/sign-in/sso and follows the redirect through the attacker /authorize endpoint back to the better-auth callback.
      • Verifies the callback succeeded and fetches /token and /userinfo, then validates the resulting session belongs to victim@example.com.
  3. Expected evidence of reproduction:
    • Vulnerable (1.6.10): registration returns 200, attacker logs show POST /token and GET /userinfo, callback redirects to /dashboard, and /api/auth/get-session returns user.email === "victim@example.com".
    • Fixed (1.6.11): registration returns 400 with error code discovery_private_host, blocking the SSRF chain before the callback is ever reached.

Evidence

  • bundle/logs/reproduction_steps.log – high-level script output
  • bundle/logs/vuln-test.log – full Vitest output for the vulnerable run
  • bundle/logs/fixed-test.log – full Vitest output for the fixed run
  • bundle/repro/runtime_manifest.json – structured runtime evidence manifest

Key excerpts from bundle/logs/reproduction_steps.log (vulnerable run):

register response: 200 {
  "tokenEndpoint": "http://127.0.0.1:9876/token",
  "userInfoEndpoint": "http://127.0.0.1:9876/userinfo",
  ...
}
callback status: 302 location: /dashboard
session: { "user": { "email": "victim@example.com", "emailVerified": true, ... } }
attacker requests: [
  { "url": "/token", "method": "POST" },
  { "url": "/userinfo", "method": "GET" }
]

Fixed run excerpt:

register response: 400 {
  "message": "The authorizationEndpoint URL (http://127.0.0.1:9876/authorize) is not publicly routable: 127.0.0.1...",
  "code": "discovery_private_host"
}

Environment captured:

  • Node.js version: v24.18.0
  • npm version: 11.16.0
  • Vulnerable package: @better-auth/sso@1.6.10 + better-auth@1.6.10
  • Fixed package: @better-auth/sso@1.6.11 + better-auth@1.6.11

Recommendations / Next Steps

  • Upgrade guidance: Upgrade @better-auth/sso to >= 1.6.11 (or the latest better-auth monorepo release). The patched release rejects private/internal OIDC URLs during provider registration unless explicitly allowlisted.
  • Configuration guidance: If an internal IdP on a private host is required, add its origin to the trustedOrigins better-auth option rather than disabling validation.
  • Testing recommendations:
    • Add regression tests that attempt POST /sso/register with loopback, RFC 1918, and cloud-metadata URLs and assert discovery_private_host.
    • Verify that trustedOrigins correctly allows legitimate internal IdPs.
    • Audit POST /sso/update-provider with the same URL set, as the fix covers both endpoints.

Additional Notes

  • Idempotency: The reproduction script was executed twice consecutively from a clean bundle and produced the same vulnerable/fixed divergence both times.
  • Limitations: The reproduction uses 127.0.0.1 for the attacker endpoints to demonstrate the SSRF without needing an external network. The same vulnerable code path would allow arbitrary internal/cloud metadata targets in a production deployment.
  • Surface: The reproduction exercises the real better-auth HTTP handler on a real TCP listener (127.0.0.1:3000), with real HTTP requests crossing the attacker (127.0.0.1:9876) and the better-auth callback boundary.

Variant Analysis & Alternative Triggers for CVE-2026-53513

Versions: versions tested:

The 1.6.11 patch for GHSA-5rr4-8452-hf4v validates user-supplied OIDC endpoint URLs at POST /sso/register and POST /sso/update-provider using a literal hostname classification (isPublicRoutableHost). This variant demonstrates that the 1.6.11 fix is bypassable by registering a provider whose endpoints use a public-looking FQDN that resolves to a private IP address. The server-side callback still fetches the attacker-controlled token and userInfo endpoints, reproducing the SSRF. The latest release (1.6.23) closes this bypass with a runtime DNS-resolution check (assertOIDCEndpointsResolvePublic), confirming the gap in the 1.6.11 fix.

Fix Coverage / Assumptions

The original fix relies on the invariant that a literal, publicly-routable hostname is safe to fetch. It covers:

  • POST /sso/register and POST /sso/update-provider when skipDiscovery: true.
  • All user-provided OIDC URLs: authorizationEndpoint, tokenEndpoint, userInfoEndpoint, jwksEndpoint, discoveryEndpoint.
  • The non-skip-discovery branch via discoverOIDCConfig and isTrustedOrigin.

The fix does not resolve hostnames to IP addresses at registration time, nor does it re-validate the stored endpoints at fetch time. It trusts that a public-looking literal hostname will not route to an internal address.

Variant / Alternate Trigger

  • Entry point: same as the parent issue: POST /api/auth/sso/register with skipDiscovery: true, then POST /api/auth/sign-in/sso, then /api/auth/sso/callback/:providerId.

  • Different input: instead of supplying literal internal IP addresses (127.0.0.1), the attacker supplies a hostname that is classified as a public FQDN but resolves to a private IP (e.g., the container hostname or an attacker-controlled public domain with a DNS record pointing to 127.0.0.1 / RFC 1918 / cloud metadata).

  • Code path involved:

    • packages/sso/src/routes/sso.tsregisterSSOProvider calls validateSkipDiscoveryEndpoints.
    • packages/sso/src/oidc/discovery.tsvalidateSkipDiscoveryEndpoint uses isPublicRoutableHost(parsed.hostname) only.
    • packages/sso/src/routes/sso.tshandleOIDCCallback fetches config.tokenEndpoint via validateAuthorizationCode and config.userInfoEndpoint via betterFetch without re-validating the resolved address.
    • In 1.6.23, the same path now calls assertOIDCEndpointsResolvePublic in ensureRuntimeDiscovery, which blocks the variant.
  • Package/component affected: npm:@better-auth/sso / better-auth monorepo packages/sso.

  • Affected versions tested:

    • 1.6.10 — vulnerable baseline reproduced.
    • 1.6.11 — bypass reproduced (DNS-shaped SSRF).
    • 1.6.23 (latest) — blocked by the runtime DNS-resolution hardening.
  • Risk level: High (the original SSRF impact is preserved on the 1.6.11 patch).

  • Consequences:

    1. Server-Side Request Forgery (SSRF) — an authenticated attacker can force the better-auth server to fetch arbitrary internal/private URLs during the OIDC callback.
    2. Account takeover is less reliable on 1.6.11 — the callback reaches the attacker endpoints, but the downstream OAuth auto-linking in the tested 1.6.11 configuration returns error=account not linked for the victim email. This is a downstream linking behavior change, not a failure of the SSRF primitive. On 1.6.10 the full account takeover still works with the original trigger.

Impact Parity

  • Disclosed/claimed maximum impact: SSRF with possible account takeover when trustEmailVerified: true.
  • Reproduced impact from this variant run:
    • Confirmed SSRF on 1.6.11: the attacker server logged POST /token and GET /userinfo requests originating from the better-auth server to an internal/private IP.
    • The callback was reached and processed the attacker token/userinfo responses; the discovery_private_host validation did not block the fetch.
    • Full account takeover to victim@example.com was not reproduced on 1.6.11 with this hostname-based variant; the flow halted at error=account not linked. It was reproduced on the 1.6.10 baseline.
  • Parity: partial — the SSRF primitive is preserved, but the account takeover chain is not guaranteed on the patched version due to an additional linking guard.
  • Not demonstrated: Cloud-metadata or internal-service exfiltration beyond the local attacker server; arbitrary remote internal targets would be reachable in a production deployment where the attacker controls a public domain's DNS record.

Root Cause

The same underlying root cause as the parent issue: attacker-controlled URLs are persisted as OIDC endpoints and then fetched server-side during the callback. The 1.6.11 fix added a literal-host check but not a DNS-resolution check, so the sink remains reachable through any public-looking hostname that resolves to an internal address. In packages/sso/src/oidc/discovery.ts (1.6.11):

function validateSkipDiscoveryEndpoint(name, endpoint, isTrustedOrigin) {
  const parsed = parseURL(name, endpoint);
  if (isPublicRoutableHost(parsed.hostname)) return;
  if (isTrustedOrigin(parsed.toString())) return;
  throw new DiscoveryError("discovery_private_host", ...);
}

isPublicRoutableHost returns true for an arbitrary FQDN such as the container hostname, because the literal name is not in any special-purpose range. Node.js then resolves that FQDN to a private IP when fetch() is issued during the callback, and the server connects to the attacker-controlled endpoint.

Reproduction Steps

  1. Reference script: bundle/vuln_variant/reproduction_steps.sh
  2. What the script does:
    • Creates three isolated Node.js workspaces using better-auth/@better-auth/sso versions 1.6.10, 1.6.11, and latest (1.6.23).
    • In each workspace it writes a Vitest test that:
      • Starts a real better-auth server on 127.0.0.1:3000 with sso({ trustEmailVerified: true }).
      • Starts an attacker server on 0.0.0.0:9876 so it can be reached via the private IP the hostname resolves to.
      • Creates a victim user and signs in as an attacker test user.
      • Registers a malicious SSO provider with skipDiscovery: true and endpoints pointing at a public-looking hostname that resolves to a private IP (http://<container-hostname>:9876/...).
      • Initiates sign-in, follows the attacker authorization redirect, and visits the callback.
    • Verifies whether the better-auth server performed server-side fetches to the attacker /token and /userinfo endpoints (SSRF) and whether the callback was blocked by endpoint validation.
  3. Expected evidence:
    • Vulnerable (1.6.10): registration returns 200, attacker logs POST /token and GET /userinfo, callback redirects to /dashboard, and the resulting session belongs to victim@example.com.
    • Fixed (1.6.11): registration returns 200 (the hostname passes literal classification), attacker logs POST /token and GET /userinfo (SSRF confirmed), and the callback is reached. The downstream account-linking step may fail with account not linked, but the SSRF primitive is preserved.
    • Latest (1.6.23): registration returns 200, but /sign-in/sso returns 400 with code: discovery_private_host and message indicating the hostname resolves to a non-public address (172.20.0.4). No server-side /token or /userinfo request is made.

Evidence

  • bundle/logs/vuln_variant.log — high-level script output.
  • bundle/logs/vuln-variant-vuln-test.log — full Vitest output for the 1.6.10 baseline.
  • bundle/logs/vuln-variant-fixed-test.log — full Vitest output for the 1.6.11 bypass run.
  • bundle/logs/vuln-variant-latest-test.log — full Vitest output for the 1.6.23 block.
  • bundle/vuln_variant/runtime_manifest.json — structured runtime evidence manifest.

Key excerpts from bundle/logs/vuln_variant.log (fixed run):

register response: 200 ...
authorizationEndpoint: "http://974542b9802b:9876/authorize",
tokenEndpoint: "http://974542b9802b:9876/token",
userInfoEndpoint: "http://974542b9802b:9876/userinfo",
...
callback status: 302 location: http://localhost:3000/api/auth/error?error=account not linked
attacker requests: [
  { "url": "/token", "method": "POST" },
  { "url": "/userinfo", "method": "GET" }
]

Latest run excerpt:

sign-in response: 400 {
  "message": "The tokenEndpoint host \"974542b9802b\" resolves to a non-publicly-routable address (172.20.0.4). ...",
  "code": "discovery_private_host"
}

Environment captured:

  • Node.js: v24.18.0
  • npm: 11.16.0
  • Tested fixed source commit: 37f60cb176cb53147da7dfd5ec15afa5b486e81e (PR #9574, released as @better-auth/sso@1.6.11).
  • Latest tested: @better-auth/sso@1.6.23 / better-auth@1.6.23.
  • Container hostname: 974542b9802b, resolving to 172.20.0.4 (RFC 1918).

Recommendations / Next Steps

  • Upgrade guidance: The 1.6.11 fix is insufficient against DNS-shaped SSRF. Users should upgrade to >= 1.6.23 (or the latest release) which performs runtime DNS resolution checks before fetching OIDC endpoints.
  • Backport recommendation: For 1.6.11, add a callback-time assertOIDCEndpointsResolvePublic check (or equivalent) before every server-side fetch to tokenEndpoint, userInfoEndpoint, and jwksEndpoint.
  • Redirect hardening: Set redirect: "error" on the token endpoint fetch in validateAuthorizationCode to prevent public endpoints from redirecting to internal addresses.
  • Pin internal IdPs correctly: If a legitimate internal IdP is required, add its origin to trustedOrigins and keep the DNS-resolution check enabled; do not rely solely on the registration-time literal check.
  • Testing recommendations: Add regression tests that register providers with hostnames resolving to loopback/RFC 1918 addresses (e.g., via local /etc/hosts or a mock DNS resolver) and assert that the callback is blocked.

Additional Notes

  • Idempotency: The reproduction script was executed twice consecutively from the same workspace and produced the same vulnerable/fixed/latest divergence both times.
  • Limitations: The in-environment demonstration uses the container hostname, which resolves to the container’s own private IP. In a real attack, the attacker would use a public domain under their control with a DNS record pointing to an internal target. The same code path is exercised in both cases.
  • Trust boundary: This is a real vulnerability because the malicious input is delivered over the authenticated POST /sso/register API and crosses the trust boundary from the attacker to the server; the server then makes unsolicited outbound requests to an internal/private address.

CVE-2026-53513 Reproduction Transcript

The agent's step-by-step process — every tool call, every handoff, the moment the exploit fired.

Full session Replay every step — scrub the timeline or play it back.

Event 1/40
0:001:45
0:00
session startedaccounts/fireworks/models/kimi-k2p7-code · GHSA-5RR4-8452-HF4V · REPRO-20
0:11
0:13
0:14
web search
0:24
0:25
web search
0:48
0:48
extract_facts
no facts extracted
0:49
0:49
0:49
supportrepro
1:17
1:21
1:22
1:22
1:26
1:26
1:26
1:26
1:29
1:29
1:32
1:37
web search
1:40
1:40
1:42
1:44
web search
1:45
08 · How to Fix

How to Fix CVE-2026-53513

Upgrade @better-auth/sso · npm to 1.6.11 or later.

Coming soon

Step-by-step mitigation and hardening guidance for CVE-2026-53513 — configuration checks, workarounds where no patch exists, and how to verify you're protected — is on the way.

10 · FAQ

FAQ: CVE-2026-53513

How does the CVE-2026-53513 SSRF-to-account-takeover chain work?

An authenticated attacker registers or updates an OIDC provider with skipDiscovery: true and attacker-controlled endpoint URLs pointing at internal services. During the OIDC callback, the server fetches those URLs -- non-blind SSRF. If the plugin is configured with trustEmailVerified: true, the attacker's own server can additionally return a forged userInfo response claiming emailVerified: true for an existing victim email, causing better-auth to link the session to that victim account -- full account takeover.

Which versions of @better-auth/sso are affected by CVE-2026-53513, and where is it fixed?

Versions >=0.1.0, <1.6.11 (also 1.7.0-beta.x) are affected. It is fixed in 1.6.11.

How severe is CVE-2026-53513?

Critical, CVSS 9.6 -- an authenticated user can force server-side requests to arbitrary internal/private URLs and, when trustEmailVerified is enabled, escalate to full account takeover of any existing email.

How can I reproduce CVE-2026-53513?

Download the verified script from this page and run it in an isolated environment against @better-auth/sso <1.6.11. It registers an SSO provider with skipDiscovery: true and attacker-controlled endpoint URLs, triggers the OIDC callback to demonstrate the server-side fetch (SSRF), and -- with trustEmailVerified enabled -- shows a forged userInfo response linking to an existing account.
11 · References

References for CVE-2026-53513

Authoritative sources for CVE-2026-53513 — official vulnerability databases and the upstream advisory. Pruva's reproduction verifies the issue firsthand; these are the primary records to corroborate it.