Skip to content
Chapter 53Lesson 9

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.

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.

Before linking
account credential
providerId 'credential' accountId ada@acme.com password ******** userId usr_a1
One credential — her password.
Same row, both sides
user the human
id usr_a1 email ada@acme.com
unchanged
After linking
account credential
providerId 'credential' accountId ada@acme.com password ******** userId usr_a1
account + new
providerId 'google' accountId 117…204 password userId usr_a1
A second row appears — same 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.

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.

1 / 1

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.

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?”

1 Click
“Sign in with Google”
2 Callback
find-or-create
3 Trust
check
4 Insert
account row
5 Signed in
+ notified

She is an existing credential user — ada@acme.com, signed up with a password back in January.

Click. Ada, an existing 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.
1 Click
“Sign in with Google”
2 Callback
find-or-create
3 Trust
check
4 Insert
account row
5 Signed in
+ notified

No (google, accountId) row exists yet, so the lookup falls to the email branch and finds her existing user.

Find-or-create. The callback runs the lookup from the last lesson. There's no (google, accountId) row yet, so it falls to the email branch and finds her existing user.
1 Click
“Sign in with Google”
2 Callback
find-or-create
3 Trust
check
4 Insert
account row
5 Signed in
+ notified
is google in trustedProviders? yes → link not is the email email_verified? — being on the list is the trust
The hinge — trust check. The question is “is the provider trusted?”, not “is the 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.
1 Click
“Sign in with Google”
2 Callback
find-or-create
3 Trust
check
4 Insert
account row
5 Signed in
+ notified
account credential
providerId 'credential' userId usr_a1
account + new
providerId 'google' userId usr_a1
Insert. A second account row appears — providerId: 'google' — against the same userId. The user row is untouched; she now has two ways in, one human.
1 Click
“Sign in with Google”
2 Callback
find-or-create
3 Trust
check
4 Insert
account row
5 Signed in
+ notified

One Google click — no separate “connect your account?” step ever appeared.

Signed in. Ada lands signed in to her original account, and a “new sign-in method added” notification email fires from the same 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.

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.

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.

1 Click
“Connect Google”
2 Elevation
check
3 OAuth
round-trip
4 Insert
account row
5 Listed in
settings

Ada is already signed in. From /settings/security/accounts she chooses to add Google — deliberate intent, not a sign-in.

Click. A signed-in Ada is on /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.
1 Click
“Connect Google”
2 Elevation
check
3 OAuth
round-trip
4 Insert
account row
5 Listed in
settings
is the session 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.
The hinge — elevation check. Attaching a new sign-in method changes the account's security posture, so it sits behind elevation: the session must have proven a credential recently. A stale or borrowed session is sent to re-prove the password first. This is the mirror of the implicit path's hinge — there it was “is the provider trusted?”; here it is “is the session fresh?”.
1 Click
“Connect Google”
2 Elevation
check
3 OAuth
round-trip
4 Insert
account row
5 Listed in
settings

Redirect → Google consent screen → callback. The exact same OAuth machinery as sign-in; only the two ends of this path differ.

OAuth round-trip. Redirect to Google, the consent screen, the callback — the same machinery as a normal sign-in. Nothing new in the middle of this path; the whole difference lives at its two ends.
1 Click
“Connect Google”
2 Elevation
check
3 OAuth
round-trip
4 Insert
account row
5 Listed in
settings
session already signed in
userId usr_a1
account + new
providerId 'google' userId usr_a1
Insert. Because she is already signed in, the callback skips find-or-create entirely — it attaches the new account row to the session's user. No email branch, no email match to trust; the session already settled whose account this is.
1 Click
“Connect Google”
2 Elevation
check
3 OAuth
round-trip
4 Insert
account row
5 Listed in
settings

Google now appears in her list of sign-in methods. She has two ways in — and a notification email confirms the new one.

Listed. Back on the settings page, Google now appears among her sign-in methods. Compared with the implicit path: intent and consent up front, and the row attaches to the session's user — so there's no find-or-create and no email match to trust.

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.

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.

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.

Auto-links A trusted provider, an email match — the row is inserted automatically.
Refuses (account-not-linked) Linking won't act — correctly, safely.
Should be blocked The library refuses, or you should refuse — a lockout or takeover risk.
A returning user signs in with Google (on trustedProviders); the Google email matches their existing account.
A signed-in user clicks Connect GitHub from settings, passes the elevation check, GitHub is trusted.
A same-email Twitter/X sign-in, with Twitter not on trustedProviders.
A same-email Google sign-in while trustedProviders is empty.
A user with only a Google account tries to disconnect Google.
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 trustedProviders list. A provider earns the list only when you judge its identity-for-an-email genuinely trustworthy; an untrusted one refuses with account-not-linked. The list is the boundary, because no email_verified backstop 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: true plus 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.

A returning user signs in with Google; Google is on trustedProviders, allowDifferentEmails is false, and the Google email is the one already on their account, so the row attaches.
You add a new provider to 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.
A signed-in user has to re-enter their password before the Connect Google button in settings will start the link.
You turn 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.
An X/Twitter sign-in arrives for an email that already has an account, X isn’t on trustedProviders, and the link is declined.

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.

Should this provider auto-link, and how?

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.