Each Advisory’s Workaround Is the Other One’s Exploit: Better Auth’s Four-CVE Batch
Four CVE records — CVE-2026-110861 through CVE-2026-110864 — were published to NVD on 14 October 2026 at 23:17:27 UTC, all four within half a second of each other, all with CNA VulnCheck and all carrying NVD status Received. They cover Better Auth (30,297 stars, 2,968 forks, MIT), the TypeScript authentication framework that a large share of the current Next.js, Nuxt and agent-backend generation uses to decide who you are.
Two of them are the story. CVE-2026-110862 (CVSS 4.0 9.3) lets an unauthenticated attacker who knows your email address redeem an OAuth state value as a Magic Link and get a session. CVE-2026-110864 (CVSS 4.0 9.1) lets an unauthenticated attacker forge an encrypted OAuth Proxy profile and get a session. Read either advisory alone and the mitigation looks clean. Read both and you find that the documented workaround for each one is the exploitation precondition for the other — and, to the maintainer’s considerable credit, both advisories say so in plain text.
The four records
CVE-2026-110862 — CVSS 4.0 9.3 (AV:N/AC:L/PR:N/UI:N/VC:H/VI:H/VA:N), CWE-287, better-auth from 1.4.0-beta.18 before 1.7.7. The upstream advisory, GHSA-965c-763c-88jm, is titled “OAuth state can be used as a Magic Link to sign in as another user” and summarises it precisely: “An attacker can start an OAuth sign-in with any email address and use the returned state value to finish a Magic Link sign-in for that address. They never prove that they can read that email.” If Magic Link sign-up is enabled and no account exists for that address, the attacker instead gets a new account that Better Auth marks email-verified — and the advisory notes the false flag “can also bypass Better Auth’s default unproven-account protections when the mailbox owner later signs in, leaving the attacker’s session in that account.” Anyone who read our account of LiteLLM’s unverified-email admin takeover knows what applications do with a verified-email flag.
CVE-2026-110864 — CVSS 4.0 9.1 (AV:N/AC:H/AT:P/PR:N/UI:N/VC:H/VI:H/VA:N), CWE-287, better-auth from 1.5.0-beta.12 before 1.7.7. Advisory GHSA-r4xp-prcw-77qf: “the OAuth Proxy plugin trusts any user profile it can decrypt. In the affected setup, Better Auth encrypts sign-in state, including data the caller chose, with the same secret and hands it to anyone who starts a social sign-in.” That is the whole bug in two sentences. The server hands out ciphertext made with secret S for one purpose, and accepts ciphertext made with secret S for a different purpose. Preconditions: the proxy has no dedicated secret or it equals the global Better Auth secret; state is cookie-backed; implicit account linking is on (the default).
CVE-2026-110863 — CVSS 4.0 8.6, CWE-287, @better-auth/sso from 1.7.0-rc.2 before 1.7.3. Advisory GHSA-mx9r-x6ww-qjw9 is the clearest written of the four. Users may register personal SSO providers — enabled by default, ten per user. Affected versions “found linked accounts by the provider’s issuer (the OIDC issuer or SAML entity ID) and the account identifier from the sign-in (the OIDC subject or SAML NameID). The lookup did not also check that the account belonged to the providerId that handled the sign-in.” So register a personal provider that copies a real provider’s issuer, assert the victim’s subject, and Better Auth matches the victim’s account. The advisory’s own closing line is the lesson: “The assertion was valid for the attacker’s provider, but it did not prove control of the legitimate provider’s account namespace.” This is the same defect class as the MCP TypeScript SDK’s issuer-binding failure: an identifier was treated as globally unique when it is only unique within an issuer.
CVE-2026-110861 — CVSS 4.0 6.3, CWE-307 (the GHSA also assigns CWE-362), affecting @better-auth/drizzle-adapter and better-auth from 1.6.17 before 1.7.7 and @better-auth/kysely-adapter from 1.6.17 before 1.7.0. Database-backed rate limiting on PostgreSQL performs the limit check non-atomically with the counter update, so concurrent bursts pass together on password sign-in, two-factor verification, one-time codes and password reset. The maintainer rates it Low and is careful about why: “The issue does not bypass password or one-time-code validation. It does not directly compromise an account.” It is a force multiplier, not a key. We agree with the low score and note only that it degrades precisely the control that makes the other three records slower to exploit at scale.
The mutual exclusion, quoted
Here is the finding, assembled from the two advisories’ own words.
GHSA-965c (Magic Link) lists among its affected conditions: “OAuth state uses account.storeStateStrategy: "database", the default when a database or secondary storage is configured. Cookie-backed state blocks this path, subject to the OAuth Proxy condition below.” Its first workaround is therefore: “Set account.storeStateStrategy to "cookie"” — followed immediately by the warning that “Cookie-backed state can expose a separate OAuth Proxy account-takeover path when the proxy shares the global secret and implicit account linking is enabled.”
GHSA-r4xp (OAuth Proxy) lists among its affected conditions: “Sign-in state is stored in a cookie (account.storeStateStrategy: "cookie").” Its second workaround is: “Set account.storeStateStrategy: "database"” — followed immediately by “If Magic Link is also enabled, switching can expose a separate cross-purpose verification-record path in affected releases.”
Database state is the Magic Link bug’s precondition and the OAuth Proxy bug’s fix. Cookie state is the OAuth Proxy bug’s precondition and the Magic Link bug’s fix. On an unpatched deployment running Magic Link, social sign-in and OAuth Proxy with a shared secret, there is no value of storeStateStrategy that is not a precondition for an unauthenticated account takeover. The configuration surface has two settings and both are exploitable; the only exit is the upgrade.
We want to be fair about what this is and is not. It is not the maintainer hiding anything — the opposite. Both advisories carry the cross-warning, both name the interaction explicitly, and both close off the obvious half-measures before a reader can waste a day on them: on Magic Link, “Setting disableSignUp: true on the Magic Link plugin is not enough. It only stops the creation of new verified accounts. It does not stop the takeover of existing accounts”; on OAuth Proxy, “Setting account.accountLinking.disableImplicitLinking: true stops this path from taking over an existing account. It does not fix the underlying flaw: the callback can still accept attacker-made data as a provider identity.” That is better advisory writing than most vendors manage with a security team. What it is is a demonstration that per-finding mitigation advice is unsound when findings share a root cause: each workaround is correct in isolation and the pair is a loop.
One root cause, one pull request
The two advisories cite the same reference, labelled “Purpose-isolation fix”: pull request #11494, fix(auth): isolate verification records and encryption purposes, opened by gustavovalverde on 30 September 2026 at 19:42:07 UTC and merged at 21:01:48 UTC — 21 files, +892/−101 across three commits, of which 560 added lines are two new test files. Release v1.7.7 was published 27 minutes after the merge, at 21:29:00 UTC.
Reading the diff makes the shared root cause obvious. Both bugs are the same omission in two different stores: a secret was bound to a value but not to a purpose.
On the Magic Link side, the verification row was written under the bare token. The fix prefixes it, and — this is the part worth copying — also validates the record’s shape rather than trusting the namespace alone, with a comment that states the threat model:
// Require the exact record shape even if a custom identifier hasher maps
// different verification purposes to the same storage key.
const magicLinkRecordSchema = z
.object({
type: z.literal("magic-link"),
email: z.email(),
name: z.string().optional(),
})
.strict();
The write becomes identifier: magicLinkIdentifier(storedToken) — that is, magic-link:<token> — with a type: "magic-link" discriminator inside the JSON value; OAuth and SAML state move to an auth-state: prefix. The redemption path gains a pre-check that declines to consume a mismatched row, with the reasoning in the source:
// Reject a known cross-purpose collision without consuming the
// other flow's record. Session issuance still requires atomic
// consumption and validation of the returned value below.
On the OAuth Proxy side, a new 46-line file, packages/better-auth/src/crypto/purpose.ts, derives a distinct key per ciphertext purpose with HKDF-SHA256 while preserving the existing secret-rotation versions:
export type EncryptionPurpose =
| "oauth-state-cookie"
| "oauth-proxy-state"
| "oauth-proxy-package"
| "oauth-proxy-profile";
const salt = utf8ToBytes("better-auth:oauth-encryption:v1");
function deriveSecret(secret: string, purpose: EncryptionPurpose): string {
return bytesToHex(
hkdf(sha256, utf8ToBytes(secret), salt,
utf8ToBytes(`better-auth:${purpose}:v1`), 32),
);
}
Four purposes that previously shared one key now have four. The structural reading: a token is not a secret, it is a secret for a purpose, and if the purpose is not bound into the storage key or the derived encryption key, then every flow writing to that store is an oracle for every other flow. Better Auth had two stores with that property and therefore two takeovers. The changesets are honest about the cost — “Pre-upgrade encrypted OAuth/SAML state cookies and OAuth Proxy payloads are rejected without a compatibility fallback” — which is the right trade and worth saying out loud before an operator discovers it in production.
The scanner gap
There is a second, checkable finding here that matters more operationally than any single CVSS number.
All four GitHub advisories render a “CVE ID: No known CVE” field, fifteen days after the CVE records were assigned against them. More consequentially, at the time of writing none of the four GHSA identifiers resolve in the GitHub global advisory database or in OSV: GET /advisories/GHSA-965c-763c-88jm, GHSA-mx9r-x6ww-qjw9, GHSA-r4xp-prcw-77qf and GHSA-44jh-23m7-hpcf each return 404, as does api.osv.dev/v1/vulns/<id> for each. A query for GET /advisories?affects=better-auth returns twenty reviewed Better Auth advisories whose most recent publication is 2 August 2026 — the September batch is simply not in the set.
The practical consequence: the repository advisory pages have been public since 29–30 September, and the tools that most teams actually rely on — npm audit, Dependabot, osv-scanner, and every SCA product that ingests GHSA or OSV — had nothing to match on for roughly two weeks, until VulnCheck minted CVE identifiers on 14 October. We have documented the fourteen-day fixed-but-unmatchable window repeatedly in the last month; this is a variant with a different mechanism. The fix was public, the advisory was public, the identifier your scanner indexes was not.
Third-party interest arrived in that window regardless. A public repository, mhtsec/better-auth-ghsa-965c-763c-88jm, described as “PoC and source analysis” for the Magic Link issue, was created on 2 October 2026 — two days after the advisory, twelve days before the CVE. We did not clone, read or execute it, and we make no claim about whether its contents work.
Severity, recorded as disagreed
The maintainer and the CNA score these differently, and we record both rather than average them. The maintainer used CVSS 3.1; VulnCheck used CVSS 4.0.
- 110862 — maintainer Critical 9.1 (
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N); VulnCheck 9.3. - 110864 — maintainer High 7.4 (
CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N); VulnCheck 9.1. The widest gap in the batch, and it is theAT:Pdimension doing the work: CVSS 4.0 records the attack-requirement as a separate axis instead of folding the conditional setup intoAC:H, which lifts the base number for a bug that only fires in a specific proxy configuration. - 110863 — maintainer High 8.1; VulnCheck 8.6.
- 110861 — maintainer Low 3.7; VulnCheck 6.3.
In every case VulnCheck scored higher. For 110864 we think the maintainer’s framing is the more useful one for triage — the preconditions are genuinely narrow and spelled out — while the CNA’s is the more useful one for a scanner that cannot read configuration. Neither is wrong; they answer different questions, and an operator who only sees one of them is making a decision with half the record.
Timeline
- 6 September 2026, 03:02 UTC — v1.7.3 published, fixing the SSO
providerIdlookup. - 29 September 2026 — GHSA-mx9r-x6ww-qjw9 (SSO) published, 23 days after its fix shipped.
- 30 September 2026, 19:42 UTC — PR #11494 opened; 21:01 UTC merged; 21:29 UTC v1.7.7 published.
- 30 September 2026 — GHSA-965c-763c-88jm, GHSA-r4xp-prcw-77qf and GHSA-44jh-23m7-hpcf published, same day as the release.
- 2 October 2026 — a third-party PoC/analysis repository for the Magic Link issue appears.
- 14 October 2026, 23:17:27 UTC — all four CVE records go public, 14 days after v1.7.7.
- 14–15 October 2026 — v1.7.8 (14 Oct, 17:50 UTC) and v1.7.9 (15 Oct, 03:52 UTC) ship; the project is moving fast and the fixed floor is well behind current.
Disclosing the critical pair on the same day the fix shipped is the correct call and we will not criticise it. The SSO record’s 23-day gap and the batch’s absence from OSV are where the friction is.
What to do
Upgrade better-auth to 1.7.7 or later (current 1.7.9) and @better-auth/sso to 1.7.3 or later. The advisories are unusually specific about the cutover and the specifics are not optional: upgrade every server that shares verification storage together; upgrade every node serving the same cookie-backed state flow together; upgrade all OAuth Proxy participants together, “including production and preview or development deployments,” because mixed participants cannot exchange payloads and there is no fallback. Pending Magic Links and in-flight OAuth, SAML and account-linking flows will not complete — expect user-visible failures during the window and plan it rather than discover it. If you wrote custom verification.storeIdentifier.overrides rules or anything that inspects verification identifiers, update them for the magic-link: and auth-state: prefixes; a custom identifier hasher must preserve the purpose prefix in the value it hashes. No schema or account migration is required.
If you cannot upgrade immediately, the honest advice is the one the advisories imply and do not quite say: do not try to configure your way out of this pair. Disable Magic Link, or disable social and Generic OAuth sign-in, or remove the OAuth Proxy plugin — remove a flow rather than shuffle storeStateStrategy between two preconditions. If you must keep all of them, the one configuration that breaks the loop is a dedicated OAuth Proxy secret, distinct from every participating environment’s global Better Auth secret, which removes the cookie branch’s precondition and lets the database-state workaround stand — and that, note, is the manual version of what crypto/purpose.ts now does automatically.
Then the post-exposure question nobody will ask you to ask. These are authentication bypasses with no memory corruption, no crash, and no anomalous request shape: a successful 110862 attack looks like a Magic Link sign-in, and a successful 110864 attack looks like a social sign-in. Upgrading stops future sessions; it does not invalidate past ones. If you ran an affected configuration while exposed, the defensible move is a session revocation sweep plus a review of accounts created or linked since 1 September with emailVerified set, because the batch’s nastiest secondary effect — a falsely verified account that absorbs the real owner when they later sign in — leaves no error in any log and will outlive the patch.
And the generalisable rule, which costs nothing to adopt and would have prevented three of these four records: bind the purpose into the key, not into the code path that reads it. A token stored under its own value, a ciphertext encrypted with the application’s one secret, an account looked up by subject without its issuer — all three are the same mistake at different layers, and all three fail the moment a second feature touches the same store. Namespacing is not cosmetics. It is the only thing that makes “this credential was issued for that” a checkable statement.
Reporting note: all four CVE IDs, publication timestamps, CVSS 4.0 vectors, CWE identifiers, affected and fixed version ranges, CNA attribution and vulnStatus values were read first-hand from the NVD 2.0 API. Advisory text, maintainer CVSS 3.1 scores and vectors, severity labels, publication dates, affected/patched package tables, reporter credits and all quoted sentences were read first-hand from the four GitHub Security Advisory pages. Pull request #11494 metadata, its file list, the diff hunks quoted, the changesets, and the contents of crypto/purpose.ts at tag v1.7.7 were read first-hand through the GitHub API; all code blocks are verbatim. Release timestamps and repository metadata (30,297 stars, 2,968 forks, MIT) are from the GitHub releases and repository APIs. The GHSA and OSV lookup results reported above were performed against the GitHub advisories API and api.osv.dev at the time of writing and reflect that moment only. Research search was performed via the Brave Search API, which returned CVE-mirror aggregators, downstream dependency-bump pull requests, and one package-management newsletter; no independent technical analysis was available. Nothing was installed, no Better Auth instance was deployed, no OAuth flow was started, no Magic Link was requested, no state value was captured or replayed, no SSO provider was registered, no PoC was downloaded or executed, and no request was sent to any third-party service.
Sources:
- NVD — CVE-2026-110862, OAuth state redeemed as a Magic Link token (9.3)
- NVD — CVE-2026-110864, OAuth Proxy encrypted-profile forgery (9.1)
- NVD — CVE-2026-110863, personal SSO provider account match (8.6)
- NVD — CVE-2026-110861, non-atomic PostgreSQL rate-limit check (6.3)
- GHSA-965c-763c-88jm — Magic Link advisory (30 September 2026)
- GHSA-r4xp-prcw-77qf — OAuth Proxy advisory (30 September 2026)
- GHSA-mx9r-x6ww-qjw9 — @better-auth/sso advisory (29 September 2026)
- GHSA-44jh-23m7-hpcf — PostgreSQL rate-limit race advisory (30 September 2026)
- better-auth PR #11494 — fix(auth): isolate verification records and encryption purposes
- better-auth v1.7.7 release (30 September 2026, 21:29 UTC)