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.
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).
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 — the most severe class — typically remotely exploitable with severe impact. Treat as an emergency.
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 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 Proof of Reproduction for CVE-2026-53513
- 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
attacker-controlled OIDC endpoint URLs (authorizationEndpoint, tokenEndpoint, userInfoEndpoint, jwksEndpoint, discoveryEndpoint) in POST /sso/register with skipDiscovery:true
- POST /api/auth/sso/register
- stored OIDC config
- POST /api/auth/sign-in/sso
- /api/auth/sso/callback/:providerId
- server-side fetch to tokenEndpoint and userInfoEndpoint
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
Root Cause and Exploit Chain for CVE-2026-53513
@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 monorepopackages/sso) - Affected versions:
>= 0.1.0, < 1.6.11(also affects1.7.0-beta.xpre-releases) - Patched version:
1.6.11 - Risk level: Critical
- Consequences:
- Server-Side Request Forgery (SSRF) – any authenticated user can force the better-auth server to fetch arbitrary internal/private URLs during the OIDC callback.
- Account Takeover (ATO) – when
trustEmailVerified: trueis enabled, a forgeduserInfopayload 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/tokenandhttp://127.0.0.1:9876/userinfo, and the callback produced a session whoseuser.emailwasvictim@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:
- URL parsing +
http(s)scheme requirement. isPublicRoutableHostfrom@better-auth/core/utils/host(rejects loopback, RFC 1918, link-local, cloud-metadata FQDNs, etc.).trustedOriginsallowlist 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
- Reference script:
bundle/repro/reproduction_steps.sh - 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:3000using thesso({ trustEmailVerified: true })plugin. - Starts an attacker-controlled HTTP server on
127.0.0.1:9876with/authorize,/token,/userinfo, and/jwksendpoints. - Creates a victim user (
victim@example.com). - Signs in as the attacker test user.
- Sends
POST /api/auth/sso/registerwithskipDiscovery: trueand attacker-controlled OIDC endpoints. - Initiates
POST /api/auth/sign-in/ssoand follows the redirect through the attacker/authorizeendpoint back to the better-auth callback. - Verifies the callback succeeded and fetches
/tokenand/userinfo, then validates the resulting session belongs tovictim@example.com.
- Starts a real better-auth HTTP server on
- Creates two isolated Node.js workspaces in the durable project cache, one using
- Expected evidence of reproduction:
- Vulnerable (
1.6.10): registration returns200, attacker logs showPOST /tokenandGET /userinfo, callback redirects to/dashboard, and/api/auth/get-sessionreturnsuser.email === "victim@example.com". - Fixed (
1.6.11): registration returns400with error codediscovery_private_host, blocking the SSRF chain before the callback is ever reached.
- Vulnerable (
Evidence
bundle/logs/reproduction_steps.log– high-level script outputbundle/logs/vuln-test.log– full Vitest output for the vulnerable runbundle/logs/fixed-test.log– full Vitest output for the fixed runbundle/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/ssoto>= 1.6.11(or the latestbetter-authmonorepo 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
trustedOriginsbetter-auth option rather than disabling validation. - Testing recommendations:
- Add regression tests that attempt
POST /sso/registerwith loopback, RFC 1918, and cloud-metadata URLs and assertdiscovery_private_host. - Verify that
trustedOriginscorrectly allows legitimate internal IdPs. - Audit
POST /sso/update-providerwith the same URL set, as the fix covers both endpoints.
- Add regression tests that attempt
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.1for 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
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/registerandPOST /sso/update-providerwhenskipDiscovery: true.- All user-provided OIDC URLs:
authorizationEndpoint,tokenEndpoint,userInfoEndpoint,jwksEndpoint,discoveryEndpoint. - The non-skip-discovery branch via
discoverOIDCConfigandisTrustedOrigin.
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/registerwithskipDiscovery: true, thenPOST /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 to127.0.0.1/ RFC 1918 / cloud metadata).Code path involved:
packages/sso/src/routes/sso.ts—registerSSOProvidercallsvalidateSkipDiscoveryEndpoints.packages/sso/src/oidc/discovery.ts—validateSkipDiscoveryEndpointusesisPublicRoutableHost(parsed.hostname)only.packages/sso/src/routes/sso.ts—handleOIDCCallbackfetchesconfig.tokenEndpointviavalidateAuthorizationCodeandconfig.userInfoEndpointviabetterFetchwithout re-validating the resolved address.- In
1.6.23, the same path now callsassertOIDCEndpointsResolvePublicinensureRuntimeDiscovery, which blocks the variant.
Package/component affected:
npm:@better-auth/sso/better-authmonorepopackages/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:
- Server-Side Request Forgery (SSRF) — an authenticated attacker can force the better-auth server to fetch arbitrary internal/private URLs during the OIDC callback.
- 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 linkedfor 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 /tokenandGET /userinforequests 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_hostvalidation did not block the fetch. - Full account takeover to
victim@example.comwas not reproduced on 1.6.11 with this hostname-based variant; the flow halted aterror=account not linked. It was reproduced on the 1.6.10 baseline.
- Confirmed SSRF on 1.6.11: the attacker server logged
- 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
- Reference script:
bundle/vuln_variant/reproduction_steps.sh - What the script does:
- Creates three isolated Node.js workspaces using
better-auth/@better-auth/ssoversions1.6.10,1.6.11, andlatest(1.6.23). - In each workspace it writes a Vitest test that:
- Starts a real better-auth server on
127.0.0.1:3000withsso({ trustEmailVerified: true }). - Starts an attacker server on
0.0.0.0:9876so 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: trueand 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.
- Starts a real better-auth server on
- Verifies whether the better-auth server performed server-side fetches to the attacker
/tokenand/userinfoendpoints (SSRF) and whether the callback was blocked by endpoint validation.
- Creates three isolated Node.js workspaces using
- Expected evidence:
- Vulnerable (
1.6.10): registration returns200, attacker logsPOST /tokenandGET /userinfo, callback redirects to/dashboard, and the resulting session belongs tovictim@example.com. - Fixed (
1.6.11): registration returns200(the hostname passes literal classification), attacker logsPOST /tokenandGET /userinfo(SSRF confirmed), and the callback is reached. The downstream account-linking step may fail withaccount not linked, but the SSRF primitive is preserved. - Latest (
1.6.23): registration returns200, but/sign-in/ssoreturns400withcode: discovery_private_hostand message indicating the hostname resolves to a non-public address (172.20.0.4). No server-side/tokenor/userinforequest is made.
- Vulnerable (
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 to172.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
assertOIDCEndpointsResolvePubliccheck (or equivalent) before every server-side fetch totokenEndpoint,userInfoEndpoint, andjwksEndpoint. - Redirect hardening: Set
redirect: "error"on the token endpoint fetch invalidateAuthorizationCodeto prevent public endpoints from redirecting to internal addresses. - Pin internal IdPs correctly: If a legitimate internal IdP is required, add its origin to
trustedOriginsand 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/hostsor 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/registerAPI 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.
Artifacts and Evidence for CVE-2026-53513
Scripts, logs, diffs, and output captured during the reproduction.
How to Fix CVE-2026-53513
Upgrade @better-auth/sso · npm to 1.6.11 or later.
FAQ: CVE-2026-53513
How does the CVE-2026-53513 SSRF-to-account-takeover chain work?
Which versions of @better-auth/sso are affected by CVE-2026-53513, and where is it fixed?
How severe is CVE-2026-53513?
How can I reproduce CVE-2026-53513?
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.