Account linking
Account linking in Better Auth, letting one person attach several sign-in methods to a single account while treating each new credential as a trust decision.
Ada signs up in January. She types her email, ada@acme.com, picks a password, and your app stores it. In March she comes back, sees the Sign in with Google button you added in the last lesson, and clicks it. Her Google account uses that same email.
Two outcomes branch off that click.
In one, Google hands you back an identity, your code doesn’t recognize it, and it creates a brand-new account. Ada now has two accounts. Her invoices, her settings, and her history live in the January one, but she’s looking at the empty March one, confused, filing a support ticket titled “where is my data.” In the other outcome, your code notices that the email matches an account it already has, attaches Google to that account, and signs her into the January one. One human, one account, two ways in.
What decides between those outcomes is a config block and one security decision. There is almost no new code here: a configuration object, two short client calls, and one error branch you handle well. What you have to learn is the judgment underneath. Every time a second credential attaches to one account, you are transferring 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”Before any configuration, get the picture of the database straight, because once you can see it, the rest of the lesson is just about gating one row.
You already own this schema. Back 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), and the account table holds a proof that they are who they say, one account row per credential. When Ada signed up with a password, you got one user row and one account row; that account carries providerId: 'credential' and holds her password hash. When someone signs in with Google instead, the account row carries providerId: 'google' and holds no password at all, just a pointer to her identity at Google.
What lets one human hold several credentials is that nothing stops two account rows from pointing at the same user. That’s the entire mechanism. Linking Google to Ada’s existing account means exactly this: a second account row appears with providerId: 'google' and its userId set to the same user she already had. Sign in through either row, whether she types the password or clicks Google, and the lookup resolves to the same person.
The following figure shows the before and the after. Watch the user row in the middle: it never changes. All that happens when Ada links Google is that a second card slots in next to her password credential, pointing at the same id.
account credential user the human account credential account + new userId. Linking changes nothing about the user. It adds one more account row pointing at the same userId.
So the takeaway is that the schema already does the join. You don’t write a query that stitches Ada’s two credentials together; the shared userId does that for free. The only two questions left are 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. Each account row is one proof. Adding a second one is your system declaring “I believe this new proof belongs to the same human as the one already here.” Get that belief wrong and you’ve handed someone a key to a door that wasn’t theirs. Everything below is about earning that belief before you act on it.
One small but load-bearing detail about that new Google card: its accountId isn’t an email. It’s the accountId , the provider’s own permanent id for that user (the OIDC sub you met in the last lesson). It’s stable, surviving even when the user renames their email at Google, and it’s safe to log, which the email is not.
Turning linking on: three knobs
Section titled “Turning linking on: three knobs”You configure linking in lib/auth.ts, under a top-level account key, a sibling of the emailAndPassword and socialProviders keys you already have. Inside it sits an accountLinking object with three properties. Here’s the whole block, and then we’ll read it as three decisions rather than three settings.
export const auth = betterAuth({ account: { accountLinking: { enabled: true, trustedProviders: ['google', 'github'], allowDifferentEmails: false, }, },});Where the block lives: a top-level account key, sibling of emailAndPassword and socialProviders. This is the home for everything about how credentials attach to a user.
export const auth = betterAuth({ account: { accountLinking: { enabled: true, trustedProviders: ['google', 'github'], allowDifferentEmails: false, }, },});The hinge of the whole lesson: 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. We unpack what “trust” means here in a moment.
export const auth = betterAuth({ account: { accountLinking: { enabled: true, trustedProviders: ['google', 'github'], allowDifferentEmails: false, }, },});The match policy. false means linking only ever happens when the emails match exactly. That’s the safe default; we come back to when you’d flip it.
There’s a correction here that reframes everything, and it’s worth slowing down for. You might expect enabled to be the on-switch: flip it true to allow linking, false to forbid it. It isn’t. enabled defaults to true. Account linking is already on. It was on in the last lesson, when a same-email Google sign-in refused to link and you saw the account-not-linked code.
So what was actually stopping it? The trustedProviders list. It’s empty by default, and an empty list means linking is on but trusts nobody. A same-email OAuth sign-in doesn’t error because linking is off; it refuses because no provider is trusted enough to act on the match. That’s the reframe: you are not switching linking on. You are configuring trust and match policy on a feature that’s already running.
Let’s read the three knobs in that light.
enabled defaults to true, and you leave it true. Setting it false is a blunt instrument: a same-email OAuth sign-in always refuses with account-not-linked, with no linking ever attempted, for any provider. That’s not the gate you want. The gate you want is the next knob, which lets you trust some providers and not others.
trustedProviders is the violet hinge, the allowlist of providers whose identity claim you’ll act on to auto-link when an email matches. In 2026, Google and GitHub earn a place here, because both have strong, well-operated verified-email systems. Twitter/X does not, since it has no reliable verified-email signal, so it stays off the list. There is no default list: unset means nobody is trusted, which is precisely why the last lesson refused. Putting a provider on this list is a curated security decision, made one provider at a time, and we’ll spend the back half of the lesson on exactly what you’re signing up for when you do it.
allowDifferentEmails defaults to false, and that’s the careful default. false means a link only ever happens on an exact email match: the Google identity’s email has to equal the account’s email. true lets a user link a provider whose email differs (Ada signs up as ada@personal.com, then later links a Google account that’s ada@acme.com). That’s a real product need for some apps and a foot-gun for others, so it gets its own decision later in this lesson. For now, name it and leave it false.
One wiring constraint is easy to trip on: a provider you list in trustedProviders must also be configured as a real entry in socialProviders. You can’t trust a provider you haven’t actually wired up. trustedProviders: ['google'] does nothing unless Google is a configured social provider with its client id and secret.
Linking on sign-in: the implicit path
Section titled “Linking on sign-in: the implicit path”There are two ways a second account row gets inserted, and they differ in who initiates the link and how much intent is behind it. Start with the one that fires automatically.
Ada is the returning user from the opening: she has a 'credential' account with ada@acme.com, and she clicks Sign in with Google. The callback runs the same find-or-create lookup you built in the last lesson. It looks for an account row matching (google, <her Google id>), and there isn’t one yet, since she’s never used Google here. So it falls to the email branch, finds her existing user by email, checks whether Google is in trustedProviders (it is), and inserts a new account row (providerId: 'google') against her existing user. Then it signs her in. One Google click, with no separate “do you want to connect Google?” step.
This is the exact branch the last lesson’s diagram labeled link and left unconfigured: the find-or-create node that refused with account-not-linked because nothing was trusted yet. You’ve now configured the trust, so the same node resolves to link instead of refuse. Nothing in the lookup is new; you’ve just told it whom to believe.
Here is the sharp edge, and it’s the opposite of what most people assume. Being on trustedProviders means the provider auto-links even when it does not assert that the email is verified. Better Auth does not additionally require an email_verified: true claim for a trusted provider: being on the list is the trust. Trusting Google here doesn’t mean “trust Google’s verified-email claim for this sign-in.” It means the stronger, blunter thing: “auto-link any Google identity onto an account with a matching email, whether or not Google says that email is verified.” Better Auth’s own docs flag trusted auto-linking as an account-takeover risk for exactly this reason. So trustedProviders is not a “check the provider’s claim” knob. It is the entire trust decision, and the curation of that list is the whole security boundary. Hold onto that, because it’s why the security section later is about the list, not about email_verified.
The following diagram walks the path one step at a time: the click, the lookup, the trust check, the row appearing, and the sign-in. Notice that the trust-check step asks “is the provider in trustedProviders?”, not “is the email verified?” Keep that distinction in mind as you scrub through, because it’s the one the takeover risk hinges on.
“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.
There’s a UX obligation hiding in this path, and skipping it is how you erode user trust. Implicit linking is surprising. Ada clicked “sign in,” not “connect my Google account”: from her side, she just signed in as usual and a permanent new way into her account quietly appeared. So tell her, in two ways, in increasing order of seriousness. Surface a one-time banner or toast when she lands (“We’ve linked your Google account to your existing sign-in”). Then, the security-grade version, fire a notification email: “a new sign-in method was added to your account.” You already have the seam for this: the same databaseHooks side-effect hook you used to send the welcome email, running through the Resend pipeline from earlier in the course. You don’t build anything new; you hang one more email off the hook. This is the chapter’s recurring discipline at its sharpest. When a security-relevant thing changes on an account, tell the human it belongs to, because that email is sometimes the only signal a legitimate owner gets that something happened.
Be honest with yourself about what this path is. Implicit on-sign-in linking is the convenience layer. It’s acceptable for forgivable email matches with a genuinely trusted provider, and it leans entirely on that trust being well-placed, with no confirmation step and no second human in the loop. For a first link, the path an experienced engineer reaches for is the explicit one, which is next.
Linking from settings: the explicit path
Section titled “Linking from settings: the explicit path”The explicit path inverts the initiative. Instead of a link happening to a user mid-sign-in, an already-signed-in user goes and asks for it.
Ada is signed in. She navigates to a settings page, say /settings/security/accounts, that lists her current sign-in methods and offers to add more. She clicks Connect Google. The client calls authClient.linkSocial({ provider: 'google', callbackURL: '/settings/security/accounts' }). That kicks off the same OAuth round-trip as sign-in (redirect to Google, consent screen, callback), but because she’s already authenticated, the callback doesn’t run find-or-create. It knows exactly whose account this is, so it attaches the new account row to her current user, full stop. She lands back on the settings page with Google now in her list of methods.
Why is this the preferred path for a first link? Because the intent and the consent are explicit and visible. She clicked “connect,” and she saw Google’s consent screen, so there is no surprise to apologize for afterward. The implicit on-sign-in path is the convenience layer built on top of this one; the explicit path is the floor you can always stand on.
The shape of the code matters less than you might expect, and that’s deliberate. Like the OAuth start in the last lesson, linkSocial triggers a browser redirect rather than a form submission. So there’s no FormData, no Zod parse, and no Result discriminant on the start of this flow; the boundary that validates everything is the callback, the catch-all route you already own. Don’t try to force the full Server-Action skeleton around the button. The button is a tiny 'use client' island that calls linkSocial on click. The settings page itself stays a Server Component that reads the user’s existing account rows and renders the list of connected methods.
What this path must carry is an elevation gate, the security decision specific to the explicit path. Linking from settings is a change to the account’s security posture, since you’re attaching a new permanent way in. So it belongs behind elevation : before the link is allowed, the user must have proven a credential recently, with a session that’s a few minutes old rather than one that’s been 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 surfaces the requires-re-authentication Result code (the same elevation pattern from the recovery-codes lesson), and the UI re-prompts for the password, refreshes the session, and retries. You won’t build the generic re-auth modal here, since that’s its own flow in a later chapter; you just name this action as sitting behind that gate.
Below are the two faces of the explicit link, the call and its guard. Read them as a pair: the button is what the user touches, and the gate is what stands 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> );}What the user touches. 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 methodWhat stands between the click and the inserted row. A stale or borrowed session can’t attach a new permanent sign-in method; surfacing 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 is built in a later chapter; this just names the gate.
The following diagram is the deliberate mirror of the on-sign-in sequence. The OAuth machinery in the middle is the same; the difference is the two ends. Up front, there’s an elevation check instead of a find-or-create. 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.
One forward-pointer while you’re on this page. The same settings page is also where a feature can request additional OAuth scopes from a provider at the moment a user opts in; linkSocial({ provider, scopes }) is the mechanism the last lesson named and deferred. You don’t expand it here, but know that the explicit link call is also the natural seam for incremental scope grants.
Unlinking and the last-method guard
Section titled “Unlinking and the last-method guard”The same settings page that connects a provider also disconnects one. Next to “Connect Google” on a linked account sits Disconnect Google, and it calls authClient.unlinkAccount({ providerId: 'google' }), which deletes that account row. (If a user somehow has multiple accounts under one provider, you pass accountId too to say which one. That’s an edge worth naming once and moving past.) After an unlink, the user has one fewer way in.
And right there is a way to lock a user out of their own account forever.
Picture a user whose only account row is Google: they signed up with Google and never set a password. They click Disconnect Google. If that delete goes through, they now have no password, no remaining provider, and no account row at all. There is no way back in. They’ve locked themselves out with one click of a button you put there. That’s the foot-gun this section is about, and the guard against it is the resilience payoff of the whole lesson: never let an unlink remove a user’s last sign-in method.
The good news is that Better Auth already guards this by default. If a user has only one account, unlinkAccount is rejected: the library refuses the delete to prevent exactly this lockout. You usually don’t write count-the-remaining-methods logic yourself, because the library counts for you and blocks it. The default can be overridden with account: { accountLinking: { allowUnlinkingAll: true } }, and you leave that at its default false.
So if the library already refuses, what’s left for you to do? Turn the refusal into a door. The library hands you a hard rejection, and a careless build surfaces that as a raw error toast (“Failed to unlink account”), leaving the user stuck and baffled. The experienced move is to catch that specific rejection and render something actionable: “You can’t disconnect your only sign-in method. Set up a password first,” with a link to the add-password flow. The library stops the foot-gun; you make the dead end a way forward.
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;}Notice how this flips the chapter’s recurring “runs perfectly, protects no one” pattern. Usually the foot-gun is an insecure default, a setting that ships permissive and silently fails to protect anyone. Here the default is safe: Better Auth refuses the last-method unlink out of the box. The foot-gun is the developer who reaches in and breaks the safe default, either flipping allowUnlinkingAll: true for “flexibility” or shipping the library’s raw refusal as a confusing error instead of an off-ramp. The test nobody writes is “unlink the last method, and check that 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 now seen both paths a credential attaches by. This section consolidates the security model they share, which is what the whole lesson was building toward. Two knobs hold the entire risk surface: trustedProviders and allowDifferentEmails.
First, a quick checkpoint to make the outcomes crisp before we go deeper. 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, stated without softening. Linking transfers a belief: “whoever controls this Google identity is the same person who set this password.” And you saw that 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 backing it up. 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 being the thing that protects the account. Better Auth’s own docs name trusted auto-linking as an account-takeover risk; believe them.
It helps to see this from the attacker’s side once. The setup the lesson has been guarding against, an app where email-password and OAuth share an email as the identifier and the link goes through on an unverified email, is exactly the pre-account takeover a security researcher reaches for first.
The mitigations are layered, 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 provider refuses withaccount-not-linkedrather than linking. This list is the boundary: there is noemail_verifiedbackstop behind it for trusted providers, so 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 act: revoke, 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 for now it’s a forward-pointer rather than a build, but linking is exactly the kind of event that belongs in it.
Then there’s the second judgment: allowDifferentEmails: true, and when it earns the call. It’s the deliberate reach for products where a user legitimately wants to use, say, a personal Google account to sign into a work-email account, where the emails differ on purpose. The cost is precise: you lose the email-match signal, which was the anchor of implicit trust. With the emails allowed to differ, “the emails match” can no longer stand 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 last combination deserves its own beat, because it’s 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.
A quick judgment check on exactly that. 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 have several email addresses spread 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 used for outbound mail, profile display, and audit identity (the original by default). Changing that canonical address is a separate flow with an entirely different threat model, and it ships in a later chapter. Don’t fold it into account-linking just because both touch the account and user tables. Linking adds proofs of identity; changing the canonical email re-points the account’s primary address. It’s a different problem.
The decision tree below walks the questions in the order an experienced engineer actually asks them: trust first, then the multi-email need, then the gating. The order is the lesson; each leaf is just where a particular path lands.
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.
More ways in, fewer ways out
Section titled “More ways in, fewer ways out”We’ll end on the upside, because account linking is easy to read as nothing but a risk to manage, and that’s only half of it.
Flip the last-method guard over and you find the reason it exists. A user with two or more linked methods can survive losing one. Forgot your password? Sign in with Google, then set a new password from settings. Lost access to your Google account? Your password still works. Every linked method is a redundant door, and a person with two doors doesn’t get locked out when one jams.
This is the same recovery principle the whole chapter keeps circling back to. Recovery codes were a fallback for a lost authenticator. A passkey could fall back to a password. The shape is always the same: a credential the user can lose should have another way in established before they lose it. Account linking is one more instance of that shape, since linking is a recovery strategy, set up in advance, the way good recovery always is.
So make the resilience visible in the product. The same settings surface that powers connect and disconnect should show “You have N sign-in methods” with the list laid out (password, Google, maybe a passkey) and a gentle nudge to keep at least two. That nudge and the last-method guard are two ends of one idea: the guard refuses to take away the resilience this section is asking you to encourage.
And that closes the chapter. You now have every credential path Better Auth ships: the password lifecycle, magic links, TOTP and passkeys for the MFA tier, social OAuth, and now the linking that lets one human hold several of those against a single account. The real deliverable across all nine flows was never the wiring. It was the judgment threaded through them: which flows to enable for which product. Account linking is the piece that makes “enable several” coherent instead of chaotic. One human, one user row, many account rows, and a clear answer, every time, to when a new proof attaches and whose word you took for it.
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.