Skip to content

Account linking and email verification without account takeovers

When "Continue with Google" joins an existing password account, when it refuses, and why an unverified email address or a trusted provider list can hand one person's account to another.

Better Auth5 min readships at docs/solutions/better-auth/account-linking-and-email-verification.md

Tags: better-auth · oauth · account-linking · email-verification · security

Sooner or later someone signs up with a password as sam@work.com and a month later clicks "Continue with Google" with the same address. You want one account, not two. The rules that decide whether that happens are also the rules that decide whether a stranger can walk into Sam's account, so they are worth understanding before you loosen any of them.

How Better Auth decides

On an OAuth sign-in for an address that already has a user, Better Auth joins the new identity to that user (implicit linking) only when all of these hold:

  1. account.accountLinking.enabled is not false.
  2. The provider says the address is verified (email_verified from Google, a verified primary email from GitHub), or the provider is listed in trustedProviders.
  3. The local user has emailVerified: true (requireLocalEmailVerified, on by default in 1.7 and unconditional from the next minor).

If any fails, the callback redirects with ?error=account_not_linked. Show that as a sentence: "This email already has an account. Sign in the way you did before, then connect this provider in Settings." A signed-in user connecting a provider from Settings (explicit linking with linkSocial) only needs rule 2 and a matching email address. That makes the session itself the only proof, so ask for a recent sign-in before linkSocial (and before adding a first password): otherwise a stolen cookie is enough to attach the thief's own Google account and keep the account after the cookie is revoked. See guarding-the-auth-api-itself.md.

Why rule 2 is about takeovers

If a provider lets people put an address on their account without proving they own it, then "the provider says this is sam@work.com" means nothing. Trusting such a provider lets anyone create an account there with Sam's address and sign in here as Sam.

  • Google verifies every address it issues and reports it. Trusting it is safe and changes little.
  • GitHub reports whether the primary address is verified, and an unverified one is possible. Do not trust it; its verified addresses still link.
  • Microsoft Entra lets a tenant administrator set any address on a user (the "nOAuth" class of bugs). Never trust it. Better Auth reads email_verified or the verified-address claims when Entra sends them.

So trustedProviders: ["google"], and a comment saying why the list is short.

Why rule 3 is about takeovers too

Suppose sign-up does not require a verified address. An attacker registers sam@work.com with a password before Sam ever visits. When Sam later signs in with Google, rule 3 stops Google from being joined to the attacker's account: Sam sees "account not linked" instead of landing in a session the attacker also holds.

Better Auth also cleans up after the attacker when Sam proves the address by email. A magic link to an unverified account deletes that account's existing passwords, OAuth links and sessions before signing Sam in, so nothing the attacker set up survives. A password reset replaces the password and, with revokeSessionsOnPasswordReset, ends the attacker's sessions.

Requiring verification, or not

emailAndPassword.requireEmailVerification decides whether a new password account can sign in before clicking the verification link.

OffOn
New user after sign-upin the product at once"check your inbox"
Sign-up with a taken address"already has an account" (reveals it exists)the same neutral panel as a new address
Squatting someone's addresspossible, cleaned up as abovenot possible
Drop-off at sign-uplowerhigher

Off is a reasonable default for most products if you keep sending the verification email on sign-up (emailVerification.sendOnSignUp: true) and show "not verified" with a resend button in settings. Turn it on for invite-only products, anything that emails other people on a user's behalf, and anything billed per seat. With it on, set sendOnSignIn: true so an unverified sign-in attempt resends the link instead of dead-ending.

With autoSignInAfterVerification: true, clicking the link creates a session. Treat it like one: a short expiry (an hour), single effective use (a second click on an already verified address does nothing), and email copy that tells anyone who did not sign up not to open it. That copy matters: it is how the real owner of a squatted address avoids verifying the squatter's account for them.

Unlinking

unlinkAccount refuses to remove the last sign-in method, and needs a fresh session (signed in within session.freshAge, a day by default), so an unattended laptop cannot quietly remove a way to sign in. Handle both errors in the UI: "add another way to sign in first", and "sign in again to make this change". Disable the button rather than letting the request fail when you already know it is the last method.

Checking your work

  • Sign up with a password, do not verify, then "Continue with Google" with the same address: account_not_linked, explained on the sign-in page.
  • Verify the address, repeat: one user, two account rows.
  • Connect GitHub from Settings with a matching verified address: linked.
  • Try to disconnect the only method: refused with a reason.
  • Make the session's created_at older than your recent sign-in window, then connect a provider: "Confirm it's you", and it works after signing in again.