Hook
The advisory scores it 9.1. A community feed that reached my desk scored it 9.3. The CVE attached to that feed — CVE-2026-110862 — does not resolve in any database I treat as authoritative. Three numbers, one vulnerability, and the numbers refuse to agree. That disagreement is the first finding, and it is not about Better Auth. It is about the supply chain of security information that wraps around it. When a downstream engineer cannot map a severity score to a real identifier, they cannot map it to a real patch. The exposure window does not close. It widens.
So I start there, with the metadata, because metadata does not mint value. It only tells you whether the thing underneath deserves your attention. Then I go to the code path itself: an open-source authentication framework for TypeScript, a CVSS 9.1 defect, and a class of bug — cross-protocol token confusion — that lets an attacker seize an account without ever touching the victim's email.
Context
Better Auth is a self-hosted authentication framework for the Node.js and Next.js ecosystem. It is not a hosted identity provider in the Clerk or Auth0 mold. It is a library you install, configure, and run inside your own application. Its selling point is developer experience: type safety, sensible defaults, a small surface of code to reason about. By the account given in the disclosure, it is "increasingly used" across that ecosystem. That phrase matters. Adoption growth and attack surface grow together. The more a framework is installed, the more research attention it attracts, and the more its architectural shortcuts stop being conveniences and start being liabilities.
The specific advisory is tracked as GHSA-965c-763c-88jm, rated 9.1. The defect class is OAuth state reuse. To understand the severity you have to understand two mechanisms Better Auth implements and, critically, how it stores them.
The first is OAuth. When a user signs in with Google, GitHub, or any other provider, the framework generates a random value called a "state" parameter. That value is sent to the provider and returned with the callback. Its job is to prove the callback belongs to a flow the application actually initiated. If an attacker can supply a state value the application will accept, the attacker can bind their own identity to someone else's session, or bind someone else's identity to theirs. The second mechanism is the Magic Link — the passwordless flow where a user clicks a link in an email and is signed in. Both mechanisms need a server-side record that links a token to a purpose. Both need that record validated before anything is trusted.
The advisory describes what happens when those two records live in the same place without separation. That is the entire story. Everything downstream is consequence.
I have audited this shape of defect before. In 2025 I spent six weeks inside a Qatari bank's real-world-asset tokenization framework, auditing the smart contract interactions with traditional banking APIs. I found two critical vulnerabilities — both in the oracle data feed process, both rooted in the same mistake: two different data purposes routed through one unsegregated channel. The bank's engineers were competent. The flaw was not incompetence. It was abstraction. Somebody, years earlier, decided that one pipe was cleaner than three. The fix took four days. The trust repair took longer.
Better Auth is that story with a different accent.
Core
The root cause is a shared verification store. Better Auth routes its OAuth state records and its Magic Link token records into the same storage abstraction, keyed by an identifier without a namespace prefix. In isolation, each write looks fine. The state gets stored. The magic-link token gets stored. Both are random, both are time-bound, both are validated on read. The problem is on read. When the framework looks up a value, it does not first ask "is this value a state or a token?" It asks "is this value present and unexpired?" Because the store does not carry the purpose of the record, the purpose is inferred from the context of the caller — and the caller's context is exactly what an attacker controls.
That is cross-protocol token confusion. Two protocols, one namespace, no type discrimination at the storage boundary. The federation layer implicitly trusts the security context instead of verifying that the context was generated for the operation in flight. This is the part most teams skip. They validate that a value exists. They do not validate that the value was created for this purpose, by this party, for this flow.
The attack chain is short. Tracing the ledger back to the zero-day exploit, step by step:
One. The attacker initiates an OAuth sign-in flow against the target application using an account they control. The framework generates a state value and stores it.
Two. The attacker captures that state value. Depending on the flow, this may be as simple as reading it from the redirect, or intercepting a callback the attacker themselves triggered. No privileged access is required.
Three. The attacker induces or waits for a victim to begin a Magic Link flow — or, in some configurations, the attacker manipulates the flow so that a state lookup resolves against a token record or vice versa. The shared store cannot tell the two apart.
Four. The confused record validates. The application issues a session. The session belongs to the attacker's identity but is now bound into a flow the victim believed they controlled — or the reverse, depending on which direction the attacker drives the confusion.
Five. Account takeover completes. The attacker never sees the victim's inbox. The attacker never needs the victim's password. The attacker never needs a valid email. That last point is the one that pushes the score to 9.1.
Read the chain again and notice what is absent. There is no exotic exploitation primitive. There is no memory corruption, no race condition that takes a thousand attempts, no dependency on a specific browser. The complexity is low, the privileges required are none, and the user interaction can be reduced toward zero in the right configuration. That combination — low complexity, no privileges, no interaction, high impact — is what a 9.1 measures. The number is not inflated. It is arithmetic.
Then there is the variant that worries me more: pre-hijacking. The advisory notes that the unverified-account protection can be bypassed. In plain terms, an attacker can plant a verified account against an email address that does not yet have an account on the target system. When the legitimate owner of that email later registers, the attacker's pre-planted identity is already sitting there, linked and trusted. This is the attack family Microsoft documented in 2022, and it is nasty precisely because it is patient. It does not need to win the race. It only needs to arrive first. An account takeover that happens before the account exists is the cleanest kind, because there is no anomaly for the user to notice — nothing was taken from them, because they had not yet deposited anything.
Here is the part the disclosure gets right, and I will give it its due before I turn. The fix is textbook. The team introduced namespace prefixes so that state records and token records occupy disjoint identifier spaces. They moved to per-purpose encryption keys, so that even if a record leaks across a boundary, the key that decrypts it is scoped to its purpose. And they did it without a schema migration, which means no user-account migration and no forced re-registration for the installed base. That last decision is not cosmetic. It is the difference between a patch that deploys and a patch that gets deferred for a quarter because the migration is scary. I have watched migrations get deferred into irrelevance. A fix that ships beats a fix that is theoretically cleaner. Priors are cheaper than promises, and a fix that arrives is worth more than a fix that is perfect.
The per-purpose-key detail tells you something about the original design. If the correct remediation is "each OAuth purpose gets its own encryption key," then the pre-fix design very likely shared a single key, or separated keys weakly. That is an inference, not a confession, but it is the kind of inference an auditor is paid to make. If key separation was thin in one place, it is worth asking where else it was thin. The disclosed flaw may be the visible one. The class is rarely a singleton.
Now the operational cost the fix exposes. The advisory warns that the remediation requires a coordinated switch. During the upgrade window, historical Magic Links fail and any pending OAuth or SAML login must be re-initiated. That is a stateful-verification tax. It is the price of storing purpose in a stateful record instead of encoding it in a self-describing, stateless token. It is also a hint about the deeper architecture: any system whose security fix requires a synchronized cutover is a system whose security posture is coupled to deployment timing. That coupling is a risk multiplier. It means every future fix of this class carries the same cutover burden, and every cutover window is an exposure window.
Now audit the configuration surface, because the configuration surface is where the real cognitive load lands. To run Better Auth safely during the disclosure window — for teams that cannot immediately upgrade — the mitigations expose three knobs: account.storeStateStrategy, storeToken, and verification.storeIdentifier. Read those three names again. A framework whose safe degradation requires a developer to understand the interaction between a state-storage strategy, a token-storage toggle, and an identifier namespace has moved security from a default into a manual. Manuals get skipped. This is the DX tradeoff in its purest form. The framework optimized for simplicity, and the vulnerability is the shadow of that optimization. When you collapse three concepts into one abstraction to make the happy path shorter, you have also collapsed the type boundary that kept them apart. The bug is not a mistake in the abstraction. The bug is the abstraction.
This is not a Better Auth-specific disease. The disclosure itself notes that Keycloak and BotSharp exhibited the same class of problem. Three frameworks, one anti-pattern: a shared verification store that does not carry purpose. That is a pattern, not a coincidence, and patterns are what security research should hunt. If the same design decision produces the same critical defect across independent codebases, then the decision is the vulnerability, and every framework that made it is a candidate. I would treat any authentication library that stores multiple token purposes in one untyped namespace as presumptively vulnerable until its maintainers publish the namespace and key separation explicitly. Audit the code, ignore the cult. The framework's popularity does not audit its storage layer, and the storage layer does not read the star count.
There is a second system failure here, and it is not in Better Auth at all. It is in the information ecosystem. The community carried a CVE number — CVE-2026-110862 — that does not resolve. It carried a severity of 9.3 that does not match the official 9.1. The official GitHub advisory holds the line at 9.1 with a correct GHSA identifier. The discrepancy is small in the abstract and enormous in practice. A downstream developer who cannot reconcile the score will hesitate. A downstream developer who cannot resolve the CVE will search for the wrong patch or no patch. Hesitation is exposure. The mismatch does not just misinform; it lengthens the time-to-remediation across the entire installed base. In an environment where AI-generated security content is proliferating, this is a new risk surface: fabricated identifiers entering the disclosure supply chain and corrupting the one signal — the authoritative patch — that the chain exists to deliver. Verify before you verify the verifier. If the verifier cannot produce a resolvable identifier, the verification is not done.
Then there is the downstream liability question, and it is the one nobody wants to answer. The applications built on Better Auth are the data controllers. They are the ones who face the GDPR or personal-data-protection notification obligations if an account takeover results in a personal-data breach. Better Auth, as the component, faces contractual pressure from those downstream parties. The responsibility allocation along that chain is not clear. The framework shipped a critical defect. The downstream integrated it, often on the framework's own recommendation of good DX. When the breach notification clock starts, whose clock is it? The disclosure does not say. Industry practice does not say. The contract does. And most of those contracts were signed by developers who never read them, because the framework was free and the integration was one command. That asymmetry — free to adopt, expensive to be liable for — is the real cost of the open-source authentication model, and it is unaccounted for in every severity score.
The single most useful stress test for any team running Better Auth right now is not "did we patch?" It is "can we prove, on a whiteboard, that our state records and our token records cannot resolve against each other?" Stress tests reveal what audits cannot. An audit checks that you did the thing on the checklist. A stress test checks whether the thing on the checklist actually holds under the conditions the attacker chooses, not the conditions you assumed. If you cannot draw the two namespaces as disjoint sets and point to the key that scopes each one, you have not verified the fix. You have verified the release notes.
Contrarian
The reflexive take is that a CVSS 9.1 in an authentication framework is disqualifying, and that developers should migrate to a hosted provider where someone else carries the pager. That take is wrong on the engineering and right only on the marketing.
Start with the fix. Namespace isolation, per-purpose keys, and a zero-migration rollout is a better remediation than most commercial identity vendors ship, because most of them never have to explain the change to a public installed base that can read the diff. The transparency here is a feature of the open model, not a bug. A hosted provider that patches the same class of flaw tells you it is "resolved" and shows you nothing. You cannot audit what you cannot read. In the self-hosted model you can read the diff, and the diff here is competent.
Then the DX tradeoff. The shared store existed because someone was optimizing for the developer's first ten minutes. That is a defensible goal. The error was not caring about DX; it was failing to pay the security tax that DX was borrowing against. Borrowed convenience always comes due, and the interest is a critical severity score. But the direction of the optimization — fewer concepts, faster integration — is correct for a framework. The lesson is not "stop optimizing DX." The lesson is "price the debt before you ship it." A framework that learned that lesson is safer than a framework that never had to.
And the disclosure quality, on the official channel, is high. A correct GHSA identifier, a stable 9.1, explicit mitigations for teams that cannot upgrade immediately, and a warning about the cutover window. That is the disclosure working. The contamination came from the community layer, not the maintainers. The maintainers did the hard part right.
Takeaway
The verdict on Better Auth is not written yet, and it will not be written by this advisory. It will be written by the next twelve months. If a third OAuth-related disclosure lands, the pattern is confirmed and the architecture, not the patch, is the problem. If nothing lands and the namespace separation holds, the fix was real. The forward question for every team that runs this framework is not whether they upgraded. It is whether they can name the second place where two purposes share one store — because the disclosed flaw was never the first one. It was the first one we got to see.