Better Auth versions 1.4.0-beta.18 up to 1.7.6 let anyone who knows a user's email address sign in as that user, without access to their mailbox. The bug, CVE-2026-110862 (GHSA-965c-763c-88jm), needs two common features turned on together: Magic Link sign-in and any social or Generic OAuth provider. Better Auth fixed it in 1.7.7 on 30 September. The CVE record followed on 14 October.
This post is for teams running Better Auth, the TypeScript authentication library, which npm counted at about 11.9 million downloads in the week of 6 to 12 October. It explains the bug from the fix commit, shows what we saw when we reproduced it locally, explains why your scanner may not flag it yet, and includes a script that checks a project for the exact configuration the advisory describes.
The short answer
Upgrade better-auth to 1.7.7 or later, and keep its adapters and plugins on the same version. There is no fixed 1.6 or 1.4 release, so the release-1.6 line (1.6.33) is still affected. You're exposed if you use the Magic Link plugin and at least one social or Generic OAuth provider, with OAuth state stored in the database, which is the default when a database is configured. If you can't upgrade today, set account.storeStateStrategy to "cookie", or switch Magic Link to storeToken: "hashed" while leaving the global verification.storeIdentifier unhashed. On 15 October, npm audit and OSV returned nothing for affected versions.
How an OAuth state became a Magic Link
Better Auth keeps several kinds of short-lived secrets in one verification table, each row with an identifier and a JSON value. Before the fix, two features wrote to that table with no marker of which feature a row belonged to.
Magic Link stored each sign-in token as identifier: <token> with a value of {"email": ..., "name": ...}. To verify a link, it looked up the row whose identifier matched the token in the URL, read email from the value, and signed that user in.
OAuth sign-in, with the default database state strategy, stored each flow as identifier: <state>. The state is the random value Better Auth puts in the provider's authorization URL, so the person starting the sign-in gets it back. The value held the callback URL, PKCE verifier and similar fields. It also held anything the client sent in the additionalData field of the sign-in request. In 1.4.0-beta.18, Better Auth changed generateState to spread additionalData into the top level of the stored object, where it used to sit nested under its own key.
Put together, an attacker could:
- call the social sign-in endpoint with
additionalData: { "email": "<victim's address>" }, - read the
statefrom the authorization URL that came back, - submit that state as the token to
/magic-link/verify.
The Magic Link verifier found a row with that identifier, read an email field, and issued a session for the victim. The OAuth provider is never contacted. If no account existed for the email and Magic Link sign-up was on, the verifier created one, marked as email-verified. The advisory notes that this flag can then bypass Better Auth's protections for unverified accounts when the real owner signs in later.
What we saw locally
We reproduced the advisory against in-memory Better Auth instances on 15 October. Each had email and password sign-up, a GitHub provider with dummy credentials and the Magic Link plugin. We created a "victim" account, then followed the three steps above as a separate client with no credentials. On 1.7.6, /magic-link/verify returned a redirect with a session cookie, and /get-session with that cookie returned the victim's email address. On 1.7.7, the same request redirected with error=INVALID_TOKEN and no cookie. Nothing left the machine. We aren't publishing the script.
What the fix changed
The fix is commit ac54bfd (#11494), titled "isolate verification records and encryption purposes". It closes the hole in three ways:
- Purpose prefixes. Magic Link rows are now stored as
magic-link:<token>and OAuth and SAML state rows asauth-state:<state>, so one feature can't find the other's rows. - Strict record parsing. Magic Link verification now requires the value to match an exact shape,
{"type": "magic-link", "email": ..., "name"?: ...}, with no extra fields. That still holds if a custom identifier hasher maps two purposes to the same key. - Separate encryption keys. Cookie-stored OAuth state and the OAuth Proxy now derive their own keys from the main secret, instead of sharing it.
The prefixes have upgrade consequences. Magic Links and OAuth or SAML flows started before the upgrade won't complete afterwards, and servers that share verification storage should be upgraded together. If you've written custom verification.storeIdentifier.overrides rules or storage integrations, they need to recognise the new magic-link: and auth-state: prefixes. No database migration is needed.
Who is exposed
The advisory lists three conditions, and all must hold:
| Condition | Default? |
|---|---|
Magic Link enabled, with storeToken: "plain", or "hashed" combined with global verification.storeIdentifier: "hashed" | "plain" is the default |
| At least one built-in social provider or Generic OAuth provider | Common in apps that offer Magic Link |
account.storeStateStrategy: "database" | Default when a database or secondary storage is configured |
The token-hashing detail is easy to get backwards. According to the advisory, hashed Magic Link tokens block the attack only while the global verification.storeIdentifier stays unset or "plain". Turning on global identifier hashing as well leaves you exposed. The advisory also warns that disableSignUp: true on the Magic Link plugin isn't enough: it stops new accounts but not takeover of existing ones.
Why your scanner is quiet
Better Auth published the repository advisory on 30 September with the fix. VulnCheck published the CVE record on 14 October. On 15 October, GitHub's global advisory database held the CVE only as an unreviewed advisory, GHSA-p7w7-v3vp-w8x8, with no npm package attached. The registry endpoint that npm audit uses returned no advisories for better-auth 1.7.6 or 1.6.33, and OSV returned nothing for 1.7.6.
So for at least two weeks after the fix, dependency scanners built on those databases have reported affected versions as clean. npm's per-version download counts show what that means in practice. In the week of 6 to 12 October, 80% of better-auth downloads were affected versions, and 18.5% were 1.7.7 or later. Some of those downloads would have been upgraded since. If you depend on scanner output alone, check this one by hand. It's a live example of why a clean scan isn't proof of safety.
Check a project
This script finds the resolved better-auth version in package-lock.json, pnpm-lock.yaml, yarn.lock or bun.lock. If the version is affected, it searches your TypeScript and JavaScript source for the three conditions. It reads text with patterns, so a configuration assembled at runtime can fool it. Treat "EXPOSED" as a strong signal and the other verdicts as reasons to check by hand. It uses only the standard library.
#!/usr/bin/env python3
"""Check a project for GHSA-965c-763c-88jm / CVE-2026-110862 (Better Auth).
Reads lockfiles for the resolved better-auth version and scans source files
for the configuration the advisory says must all be present. Static and
approximate: it reads text, it doesn't run your config. Standard library only.
"""
import json, os, re, sys
LOCKS = ("package-lock.json", "pnpm-lock.yaml", "yarn.lock", "bun.lock")
SRC = (".ts", ".tsx", ".js", ".mjs", ".cjs", ".mts", ".cts")
SKIP = {"node_modules", ".git", "dist", "build", ".next"}
def parse(v):
m = re.match(r"(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.]+))?$", v)
if not m:
return None
pre = tuple(int(x) if x.isdigit() else x for x in m[4].split(".")) if m[4] else None
return tuple(map(int, m.groups()[:3])), pre
def affected(v):
p = parse(v)
if not p:
return None
core, pre = p
if core >= (1, 7, 7):
return False
if core > (1, 4, 0):
return True
if core == (1, 4, 0):
return pre is None or (pre[0] == "beta" and len(pre) > 1 and isinstance(pre[1], int) and pre[1] >= 18) or pre[0] == "rc"
return False
def lock_versions(root):
found = set()
for dirpath, dirs, files in os.walk(root):
dirs[:] = [d for d in dirs if d not in SKIP]
for f in files:
if f not in LOCKS:
continue
text = open(os.path.join(dirpath, f), encoding="utf-8", errors="replace").read()
if f == "package-lock.json":
for key, meta in json.loads(text).get("packages", {}).items():
if key.endswith("node_modules/better-auth") and "version" in meta:
found.add(meta["version"])
else:
found.update(re.findall(r"better-auth@(?:npm:)?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?)", text))
found.update(re.findall(r"/better-auth@(\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?)", text))
return sorted(found)
def scan_source(root):
text = ""
for dirpath, dirs, files in os.walk(root):
dirs[:] = [d for d in dirs if d not in SKIP]
for f in files:
if f.endswith(SRC):
text += open(os.path.join(dirpath, f), encoding="utf-8", errors="replace").read() + "\n"
has = lambda pat: re.search(pat, text) is not None
return {
"magic_link": has(r"\bmagicLink\s*\("),
"oauth": has(r"\bsocialProviders\s*:") or has(r"\bgenericOAuth\s*\("),
"cookie_state": has(r"storeStateStrategy\s*:\s*['\"]cookie['\"]"),
"hashed_token": has(r"storeToken\s*:\s*['\"]hashed['\"]"),
"custom_token": has(r"storeToken\s*:\s*\{"),
"global_hashed_id": has(r"storeIdentifier\s*:\s*['\"]hashed['\"]") or has(r"storeIdentifier\s*:\s*\{"),
}
def main(root):
versions = lock_versions(root)
if not versions:
print("better-auth not found in any lockfile")
return 0
bad = [v for v in versions if affected(v)]
print("better-auth versions in lockfiles:", ", ".join(versions))
if not bad:
print(" no affected version (fixed in 1.7.7)")
return 0
c = scan_source(root)
print(" affected version(s):", ", ".join(bad))
print(" Magic Link plugin:", "yes" if c["magic_link"] else "not found")
print(" social or Generic OAuth sign-in:", "yes" if c["oauth"] else "not found")
print(" OAuth state in cookie:", "yes" if c["cookie_state"] else "no (database, the default)")
token = "custom" if c["custom_token"] else "hashed" if c["hashed_token"] else "plain (default)"
print(" Magic Link storeToken:", token, "| global storeIdentifier hashed:", "yes" if c["global_hashed_id"] else "no")
blocked_by_token = token == "hashed" and not c["global_hashed_id"]
if not (c["magic_link"] and c["oauth"]):
verdict = "upgrade anyway; the vulnerable combination wasn't found in source"
elif c["cookie_state"]:
verdict = "cookie state blocks the main path (check the OAuth Proxy note in the advisory); upgrade"
elif blocked_by_token:
verdict = "hashed Magic Link tokens block this path; upgrade"
elif token == "custom":
verdict = "custom token storage: check by hand, and upgrade"
else:
verdict = "EXPOSED: account takeover by email address; upgrade to 1.7.7 or later now"
print(" verdict:", verdict)
return 1 if verdict.startswith("EXPOSED") else 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1] if len(sys.argv) > 1 else "."))
We ran it on three fixture projects: one on 1.7.6 with Magic Link and GitHub sign-in, one on 1.6.33 with cookie state, and one on 1.7.10:
$ python3 better_auth_ghsa_965c_check.py ./exposed
better-auth versions in lockfiles: 1.7.6
affected version(s): 1.7.6
Magic Link plugin: yes
social or Generic OAuth sign-in: yes
OAuth state in cookie: no (database, the default)
Magic Link storeToken: plain (default) | global storeIdentifier hashed: no
verdict: EXPOSED: account takeover by email address; upgrade to 1.7.7 or later now
exit 1
$ python3 better_auth_ghsa_965c_check.py ./workaround
better-auth versions in lockfiles: 1.6.33
affected version(s): 1.6.33
Magic Link plugin: yes
social or Generic OAuth sign-in: yes
OAuth state in cookie: yes
Magic Link storeToken: plain (default) | global storeIdentifier hashed: no
verdict: cookie state blocks the main path (check the OAuth Proxy note in the advisory); upgrade
exit 0
$ python3 better_auth_ghsa_965c_check.py ./fixed
better-auth versions in lockfiles: 1.7.10
no affected version (fixed in 1.7.7)
exit 0
It exits with status 1 only for the exposed case, so you can drop it into CI across many repositories.
What to do
- Upgrade to 1.7.7 or later, together with every
@better-auth/*package, and plan a coordinated cutover if several servers share verification storage. Users with a sign-in flow in progress will need to start again. - On 1.6 or 1.4, move to 1.7. There's no backport.
- If you can't upgrade today, apply one of the advisory's workarounds: cookie state storage (with a dedicated OAuth Proxy
secretfirst, if you use the proxy), hashed Magic Link tokens with the global identifier left plain, or disabling Magic Link or OAuth sign-in until you can patch. - Look for past use. Magic Link verifications for accounts with no matching magic-link request, and new accounts marked email-verified that never received an email, are worth checking. Your email provider's send logs are the easiest record to compare against.
The fair view
This is a serious bug with a narrow trigger: it needs two specific features enabled together, and many Better Auth apps use only one of them. The maintainers fixed it properly. Separating verification records by purpose closes this whole class of bug, not just this one path. The advisory also explains the conditions, workarounds and upgrade side effects clearly, and credits the reporters, rick-v12 and bazumo.
The lesson is the shared table. When several features store secrets in one place, each lookup needs to check what kind of record it found, not only that one exists. A field that one feature lets the client set freely can become a trusted field to another. If your own code stores tokens for different purposes side by side, the purpose prefix and strict parsing in this fix are worth copying.