Account linking
Attaching several sign-in methods to one account with Better Auth, treating each new credential as a trust decision.
Ada signs up in January with ada@acme.com and a password. In March she returns, clicks the Sign in with Google button, and her Google account uses that same email.
Two things can happen. Your code doesn’t recognize the Google identity, creates a brand-new account, and Ada lands in an empty one while her invoices, settings, and history sit in the January account she can’t see. Or your code notices the matching email, attaches Google to the existing account, and signs her into January. One human, one account, two ways in.
The difference is a small config block and one security decision, not much code: a configuration object, two short client calls, and one error branch. The judgment is the hard part. Each time a second credential attaches to an account, you transfer trust from one proof of identity to another, and the whole question is whose word you took for it.
One human, many credentials
Section titled “One human, many credentials”Configuration comes later; first see the database, because once you can, linking is just inserting one row.
You already own this schema. When you set up Better Auth, you saw that it splits a person across two tables. The user table holds the human (their id, name, and canonical email); the account table holds one proof of identity per row, one account per credential. When Ada signed up with a password, you got one user row and one account row carrying providerId: 'credential' and her password hash. Sign in with Google instead and the account row carries providerId: 'google' and no password, just a pointer to her identity at Google.
One human holds several credentials because nothing stops two account rows from pointing at the same user. Linking Google to Ada means exactly that: a second account row appears with providerId: 'google' and its userId set to the user she already had. Sign in through either row, password or Google, and the lookup resolves to the same person.
account credential user the human account credential account + new userId. Linking adds one account row pointing at the same userId; the user row is untouched.
So the schema already does the join. You never write a query stitching Ada’s two credentials together; the shared userId does it for free. Only two questions remain: when the second account row gets inserted, and on whose authority.
The second question is the one that matters, because linking is a trust transfer between proofs of identity. Adding a second account row is your system declaring that this new proof belongs to the same human as the one already there. Get that belief wrong and you have handed someone a key to a door that was never theirs. Everything below is about earning that belief before you act on it.
One detail about the new Google row: its accountId is not an email. It is the accountId , the provider’s permanent id for that user (the OIDC sub from the last lesson). It is stable, surviving even when the user renames their email at Google, and it is safe to log, which the email is not.
The accountLinking config
Section titled “The accountLinking config”Linking lives in lib/auth.ts, under a top-level account key alongside the emailAndPassword and socialProviders keys you already have. Inside it, an accountLinking object holds three properties. Read them as three decisions, not three settings.
export const auth = betterAuth({ account: { accountLinking: { enabled: true, trustedProviders: ['google', 'github'], allowDifferentEmails: false, }, },});The home for everything about how credentials attach to a user: a top-level account key, sibling of emailAndPassword and socialProviders.
export const auth = betterAuth({ account: { accountLinking: { enabled: true, trustedProviders: ['google', 'github'], allowDifferentEmails: false, }, },});The allowlist of providers you trust enough to auto-attach on an email match. It’s empty by default, which is why a same-email Google sign-in refused in the last lesson.
export const auth = betterAuth({ account: { accountLinking: { enabled: true, trustedProviders: ['google', 'github'], allowDifferentEmails: false, }, },});The match policy. false links only on an exact email match, the safe default.
You might expect enabled to be the on-switch. It isn’t: enabled defaults to true, so account linking was already on in the last lesson, when a same-email Google sign-in refused with the account-not-linked code.
What stopped it was trustedProviders. Empty by default, an empty list means linking is on but trusts nobody, so a same-email OAuth sign-in refuses not because linking is off but because no provider is trusted enough to act on the match. You aren’t switching linking on; you’re configuring trust and match policy on a feature that’s already running.
enabled stays true. Setting it false is a blunt instrument: every same-email OAuth sign-in refuses with account-not-linked, for every provider, with no linking ever attempted. The gate you actually want is the next knob, which trusts some providers and not others.
trustedProviders is the allowlist of providers whose identity claim you’ll act on to auto-link on an email match. In 2026, Google and GitHub earn a place, because both run strong verified-email systems; Twitter/X has no reliable verified-email signal, so it stays off. There is no default list: unset means nobody is trusted, which is why the last lesson refused. Each addition is a curated security decision, made one provider at a time, and the back half of the lesson covers exactly what you’re signing up for.
allowDifferentEmails defaults to false, meaning a link only ever happens on an exact email match: the Google identity’s email must equal the account’s. Setting it true lets a user link a provider whose email differs (Ada signs up as ada@personal.com, then links a Google account that’s ada@acme.com). That’s a real need for some apps and a foot-gun for others, so it gets its own decision later. For now, leave it false.
One wiring constraint trips people up: a provider in trustedProviders must also be a configured entry in socialProviders. trustedProviders: ['google'] does nothing unless Google is wired up with its client id and secret.
Linking on sign-in: the implicit path
Section titled “Linking on sign-in: the implicit path”A second account row can be inserted two ways, differing in who initiates the link and how much intent is behind it. Start with the one that fires automatically.
Ada, the returning user from the opening, has a 'credential' account at ada@acme.com and clicks Sign in with Google. The callback runs the same find-or-create lookup from the last lesson. No (google, accountId) row exists yet, so it falls to the email branch, finds her existing user by email, sees that Google is in trustedProviders, and inserts a new account row (providerId: 'google') against that user before signing her in. One Google click, no separate “connect Google?” step. This is the link branch the last lesson’s diagram left unconfigured: the node that refused with account-not-linked when nothing was trusted now resolves to link.
Here is the part most people get backwards. A trusted provider auto-links even when it does not assert that the email is verified. Better Auth does not also require an email_verified claim once a provider is on trustedProviders; being on the list is the trust. So the list is not a “check the provider’s claim” knob, it is the entire trust decision, which is why Better Auth’s docs flag trusted auto-linking as an account-takeover risk. As the diagram below shows, the trust-check step asks “is the provider in trustedProviders?”, not “is the email verified?”
“Sign in with Google”
find-or-create
check
account row
+ notified
She is an existing credential user — ada@acme.com, signed up with a password back in January.
credential user at ada@acme.com, clicks Sign in with Google — same email her Google account
uses. The same OAuth round-trip as a normal sign-in begins.
“Sign in with Google”
find-or-create
check
account row
+ notified
No (google, accountId) row exists yet, so the lookup falls to the email branch and finds her existing user.
(google, accountId) row yet, so it
falls to the email branch and finds her existing user.
“Sign in with Google”
find-or-create
check
account row
+ notified
google in trustedProviders?
yes → link
not email_verified?google is on trustedProviders, so it links. This is the exact node the
last lesson refused with account-not-linked
when the list was empty.
“Sign in with Google”
find-or-create
check
account row
+ notified
account credential account + new account row appears — providerId: 'google' — against the same userId. The user row is untouched; she now has
two ways in, one human.
“Sign in with Google”
find-or-create
check
account row
+ notified
One Google click — no separate “connect your account?” step ever appeared.
databaseHooks seam as the welcome
email. This is the link branch of the last lesson's lookup —
the node that refused, now that a trusted provider is configured.
Implicit linking is surprising: Ada clicked “sign in,” not “connect my Google account,” yet a permanent new way into her account appeared. Tell her, in two escalating ways. Surface a one-time banner when she lands (“We’ve linked your Google account to your existing sign-in”), then fire a notification email: “a new sign-in method was added to your account.” You already have the seam, the same databaseHooks hook and Resend pipeline that send the welcome email; you hang one more email off it. When something security-relevant changes on an account, tell the human it belongs to, because that email is sometimes the only signal a legitimate owner gets.
Implicit on-sign-in linking is the convenience layer. It is acceptable for an email match with a trusted provider, but it leans entirely on that trust, with no confirmation and no second human in the loop. For a first link, prefer the explicit path, which is next.
Linking from settings: the explicit path
Section titled “Linking from settings: the explicit path”In the explicit path, an already-signed-in user goes and asks for the link instead of receiving one mid-sign-in.
Ada is signed in and navigates to a settings page, say /settings/security/accounts, that lists her sign-in methods and offers to add more. She clicks Connect Google, and the client calls authClient.linkSocial({ provider: 'google', callbackURL: '/settings/security/accounts' }). That runs the same OAuth round-trip as sign-in (redirect to Google, consent screen, callback), but because she’s already authenticated, the callback skips find-or-create: it knows whose account this is and attaches the new account row to her current user. She lands back on the settings page with Google in her list of methods.
This is the preferred path for a first link because the intent and consent are explicit: she clicked “connect” and saw Google’s consent screen, so there’s no surprise to apologize for afterward.
Like the OAuth start in the last lesson, linkSocial triggers a browser redirect, not a form submission, so there’s no FormData, no Zod parse, and no Result discriminant on the start of the flow; the boundary that validates everything is the callback you already own. The button is a tiny 'use client' island that calls linkSocial on click. The settings page stays a Server Component that reads the user’s existing account rows and renders the list of connected methods.
The explicit path must carry an elevation gate. Attaching a new permanent way in changes the account’s security posture, so it belongs behind elevation : before the link is allowed, the session must have proven a credential recently, within a few minutes rather than after sitting open for three weeks. A stale or borrowed session should not be able to bolt a new sign-in method onto an account. When the session is too old, the action returns the requires-re-authentication Result code (the elevation pattern from the recovery-codes lesson), and the UI re-prompts for the password, refreshes the session, and retries. You only name this action as sitting behind that gate here; the generic re-auth modal is its own flow in a later chapter.
Here are the two faces of the explicit link: the button the user touches, and the gate between the click and the inserted row.
'use client';
export function ConnectGoogleButton() { return ( <button onClick={() => authClient.linkSocial({ provider: 'google', callbackURL: '/settings/security/accounts', }) } > Connect Google </button> );}It triggers a redirect, so there’s no FormData, Zod, or Result on this side; the callback is the boundary.
const { session } = await requireFreshSession();if (session.freshAge > FRESH_AGE_LIMIT) { return err('requires-re-authentication');}// recent credential proof — the link may attach a new sign-in methodA stale or borrowed session can’t attach a new permanent sign-in method; requires-re-authentication triggers a re-prompt, then a refresh, then a retry (the elevation pattern from the recovery-codes lesson). The generic re-auth modal comes in a later chapter.
This diagram mirrors the on-sign-in sequence. The OAuth machinery in the middle is identical; only the two ends differ. Up front there’s an elevation check instead of a find-or-create, and at the back the row attaches to the session’s user with no email lookup, because the system already knows who’s asking.
“Connect Google”
check
round-trip
account row
settings
Ada is already signed in. From /settings/security/accounts she chooses to add Google — deliberate intent, not a sign-in.
/settings/security/accounts, looking at her sign-in
methods, and clicks Connect Google. The client
calls authClient.linkSocial({ provider: 'google' }). Intent is explicit — she asked for
this.
“Connect Google”
check
round-trip
account row
settings
fresh enough?
fresh → link
stale → requires-re-authentication — re-prove the
password, then attach a new way in. No find-or-create:
the session already says who she is.
“Connect Google”
check
round-trip
account row
settings
Redirect → Google consent screen → callback. The exact same OAuth machinery as sign-in; only the two ends of this path differ.
“Connect Google”
check
round-trip
account row
settings
session already signed in account + new account row to the session's user. No email branch, no email match to
trust; the session already settled whose account this is.
“Connect Google”
check
round-trip
account row
settings
Google now appears in her list of sign-in methods. She has two ways in — and a notification email confirms the new one.
The same settings page is also where a feature can request additional OAuth scopes when a user opts in, via linkSocial({ provider, scopes }) — the mechanism the last lesson named and deferred. The explicit link call is the natural seam for incremental scope grants.
Unlinking and the last-method guard
Section titled “Unlinking and the last-method guard”The settings page that connects a provider also disconnects one. Next to a linked account, Disconnect Google calls authClient.unlinkAccount({ providerId: 'google' }), which deletes that account row. (If a user has multiple accounts under one provider, pass accountId to say which one.)
This is where a user can lock themselves out for good. Picture a user whose only account row is Google: they signed up with Google and never set a password. They click Disconnect Google, the delete goes through, and now there is no password, no provider, and no account row left, so there is no way back in. The guard against this is the rule the lesson is built toward: never let an unlink remove a user’s last sign-in method.
Better Auth enforces this by default. When a user has only one account, unlinkAccount is rejected with UNABLE_TO_UNLINK_LAST_ACCOUNT; the library counts the remaining methods for you, so you don’t write that logic yourself.
Since the library already refuses, your job is to turn the refusal into a next step. A careless build surfaces the rejection as a raw error toast (“Failed to unlink account”), leaving the user stuck. Catch that specific code instead and render something actionable: “You can’t disconnect your only sign-in method. Set up a password first,” linking to the add-password flow.
const { error } = await authClient.unlinkAccount({ providerId: 'google' });
if (error?.code === 'UNABLE_TO_UNLINK_LAST_ACCOUNT') { // last-method refusal is recoverable: route to add-password, don't surface as an error showAddPasswordPrompt({ next: '/settings/security/password' }); return;}Here the safe behavior is the default, and the foot-gun is the developer who breaks it, either by flipping allowUnlinkingAll: true for “flexibility” or by shipping the raw refusal as a confusing error instead of an off-ramp. The test nobody writes: unlink the last method, and check the user gets a helpful next step rather than a dead end.
The trust you’re transferring
Section titled “The trust you’re transferring”You’ve seen both paths a credential attaches by. Two knobs hold the entire risk surface they share: trustedProviders and allowDifferentEmails.
Sort each scenario by what the system should do with it.
Sort each linking scenario by what the system does — or should do — with it. Drag each item into the bucket it belongs to, then press Check.
trustedProviders); the Google email matches their existing account.trustedProviders.trustedProviders is empty.allowDifferentEmails: true plus implicit on-sign-in linking, attaching a different, unverified email automatically.Now the attack. Linking transfers a belief: “whoever controls this Google identity is the same person who set this password.” A trusted provider auto-links on an email match even without an email_verified claim, so that belief lives entirely in which providers you put on the list. There’s no second check behind it. If a listed provider’s identity for ada@acme.com ever falls into the wrong hands, whether through a provider bug, a Google Workspace edge case, or a domain takeover where an attacker stands up a Google account on Ada’s domain, that attacker can link into Ada’s account and sign in without ever knowing her password. The password stops protecting the account. Better Auth’s docs name trusted auto-linking as an account-takeover risk.
This is also the pre-account takeover a security researcher reaches for first: an app where email-password and OAuth share an email as the identifier, and the link goes through on an unverified email.
The mitigations layer, and each one is something you’ve already built or named in this chapter:
- A tightly curated
trustedProviderslist. A provider earns the list only when you judge its identity-for-an-email genuinely trustworthy; an untrusted one refuses withaccount-not-linked. The list is the boundary, because noemail_verifiedbackstop sits behind it for trusted providers. The curation is the security. - Elevation on the explicit path. Link-from-settings re-proves the user (
freshAge) before attaching a method, so a stolen live session can’t bolt one on. - A notification email on every new method. Even if a link slips through, the legitimate owner is told immediately and can revoke it, change the password, or contact you.
- An audit-log entry for the linking event. A durable record of “this credential attached at this time.” The audit-log table comes in a later chapter, so this is a forward-pointer for now, but linking is exactly the kind of event that belongs in it.
Then the second judgment: when allowDifferentEmails: true earns the call. It’s the deliberate reach for products where a user legitimately wants to sign into a work-email account with, say, a personal Google account, the emails differing on purpose. The cost is precise: you lose the email-match signal that anchored implicit trust. Once the emails can differ, “the emails match” no longer stands in for “same person.” So allowDifferentEmails: true is only safe paired with explicit, elevated link-from-settings, never with implicit on-sign-in linking.
That combination is the single most dangerous configuration in this lesson:
allowDifferentEmails: trueplus implicit on-sign-in linking means the library will automatically attach a provider whose email is different from, and unverified against, the target account, with no human intent and no email match to vouch for it. Don’t ship it.
Default allowDifferentEmails to false. Flip it only when the product genuinely needs the multi-email pattern, and only behind the explicit, elevated path.
More than one of these is unsafe, so pick all the unsafe ones.
Which of these account-linking setups is/are unsafe? Select all that apply.
trustedProviders, allowDifferentEmails is false, and the Google email is the one already on their account, so the row attaches.trustedProviders mainly because a lot of your users have it, without first deciding whether its “this email is the user’s” signal is one you’d stake an account on.allowDifferentEmails, leave on-sign-in linking active, and a returning user’s Google login attaches to an account whose email it doesn’t even match.trustedProviders, and the link is declined.trustedProviders because it’s popular” and “allowDifferentEmails on with on-sign-in linking still active.” Both act on trust nobody earned: listing a provider you haven’t actually judged hands an account-takeover lever to whatever its email signal turns out to be worth, and dropping the email-match requirement while still auto-linking on sign-in throws away the one anchor that stood in for same person. The other three are the safe shapes — a trusted, email-matched link; an elevation gate in front of an explicit connect; and a correct refusal of an untrusted provider. Linking is only ever as safe as the weakest belief you let it act on by itself.One last distinction, so you don’t conflate two flows that share a schema. After linking, a user can hold several email addresses across their account rows: 'credential' at ada@acme.com, Google at ada@acme.com, and (once allowDifferentEmails is on) GitHub at a.lovelace@gmail.com. But user.email still holds exactly one canonical email : the one address for outbound mail, profile display, and audit identity (the original by default). Changing that canonical address is a separate flow with a different threat model, and it ships in a later chapter. Linking adds proofs of identity; changing the canonical email re-points the account’s primary address. Don’t fold them together just because both touch the account and user tables.
The decision tree below walks the questions in the order an experienced engineer asks them: trust first, then the multi-email need, then the gating.
It’ll refuse with account-not-linked, which is the correct, safe outcome.
An untrusted provider that refuses is working exactly as intended, so don’t add
it to the list to make the refusal go away.
On-sign-in linking is fine here: the exact email match is your trust anchor, and the provider is trusted. Fire the notification email and audit the event.
allowDifferentEmails: true plus implicit on-sign-in linking attaches a
different, unverified email automatically, with no intent behind it. Require
the explicit path first, or don’t allow different emails at all.
Different emails are fine because the user proved intent and re-authenticated, and you notify on every link. Never let this configuration link on sign-in.
Multiple sign-in methods as recovery
Section titled “Multiple sign-in methods as recovery”Linking reads as a risk to manage, but that’s only half of it. A user with two or more linked methods can survive losing one. Forgot your password? Sign in with Google, then set a new one from settings. Lost access to Google? Your password still works. Every linked method is a redundant door, and someone with two doors isn’t locked out when one jams. That redundancy is what the last-method guard protects: linking is recovery set up in advance, before the credential is lost.
So make the resilience visible. The settings surface that powers connect and disconnect should show “You have N sign-in methods”, list them (password, Google, maybe a passkey), and nudge the user to keep at least two. The nudge and the guard are two ends of one idea: the guard refuses to remove the resilience the nudge asks you to build.
External resources
Section titled “External resources”The accountLinking config: enabled, trustedProviders, allowDifferentEmails, and the trusted-auto-link takeover warning.
Client API reference for the two link/unlink calls used on the settings page.
freshAge and fresh-session handling — the elevation gate the explicit link sits behind.
The trust-transfer threat framing for binding multiple credentials to one identity.