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:
account.accountLinking.enabledis notfalse.- The provider says the address is verified (
email_verifiedfrom Google, a verified primary email from GitHub), or the provider is listed intrustedProviders. - 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_verifiedor 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.
| Off | On | |
|---|---|---|
| New user after sign-up | in 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 address | possible, cleaned up as above | not possible |
| Drop-off at sign-up | lower | higher |
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.
The verification link is a sign-in link
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
accountrows. - 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_atolder than your recent sign-in window, then connect a provider: "Confirm it's you", and it works after signing in again.