OAuth Account Linking#
OAuth account linking is the mechanism by which an incoming OAuth identity is associated with an existing user record when the provider's email matches an account already in the database. The core logic lives in handleOAuthUserInfo() in packages/better-auth/src/oauth2/link-account.ts.
There are two distinct paths:
- Implicit linking — automatic during OAuth sign-in when a matching email is found and all security conditions pass.
- Explicit linking — initiated by an already-authenticated user calling
authClient.linkSocial(). No new session is created; the callback only attaches the new account row .
Implicit Linking Security Gate#
When handleOAuthUserInfo() finds an existing user by email but no pre-existing account row for the incoming (providerId, accountId) pair, it evaluates the implicit linking gate . Linking is blocked — returning { error: "account not linked" } — if any of the following hold:
| Condition | Config key |
|---|---|
Provider is not trusted and IdP email_verified is false | account.accountLinking.trustedProviders |
Local user row has emailVerified: false | account.accountLinking.requireLocalEmailVerified (default: true) |
| Account linking is disabled entirely | account.accountLinking.enabled = false |
| Implicit linking is explicitly disabled | account.accountLinking.disableImplicitLinking = true |
When the gate blocks linking, the OAuth callback redirects with ?error=account_not_linked. The full set of OAuth error codes is defined in OAUTH_CALLBACK_ERROR_CODES in packages/better-auth/src/oauth2/errors.ts.
requireLocalEmailVerified (default: true)#
This gate was introduced in PR #9578 to close a pre-account-hijacking vector (advisory GHSA-g38m-r43w-p2q7): an attacker pre-registers a victim's email without verifying it, then the victim's first OAuth sign-in would silently bind to the attacker's row .
Default (true): implicit linking is blocked unless dbUser.user.emailVerified is true, regardless of whether the provider is trusted . The behavior is intentional — trustedProviders bypasses only the IdP-side email_verified check, not the local row check.
Opt-out (false): allows linking to proceed against an unverified local row. If the IdP's email_verified is true, the local row is then promoted to emailVerified: true after linking . This option is deprecated — the inline FIXME comment flags it for removal in the next minor release, making the gate unconditional .
Configuration Reference#
All options live under account.accountLinking in betterAuth({...}) :
| Option | Type | Default | Description |
|---|---|---|---|
enabled | boolean | true | Master switch; false disables all linking (implicit and explicit) |
disableImplicitLinking | boolean | false | When true, blocks auto-linking on sign-in; requires explicit linkSocial() |
requireLocalEmailVerified | boolean | true | Blocks linking to local rows with emailVerified: false (deprecated) |
trustedProviders | string[] or async function | — | Bypasses IdP email_verified check; local emailVerified gate still applies |
allowDifferentEmails | boolean | false | Permits explicit linking of accounts with non-matching emails (⚠️ takeover risk) |
allowUnlinkingAll | boolean | false | Allows users to unlink their last remaining account |
updateUserInfoOnLink | boolean | false | Copies provider profile (name, image, mapProfileToUser fields) onto local user on link; never changes email or emailVerified |
trustedProviders accepts a static array or an async function (request?) => string[] . Providers in this list bypass the IdP's email_verified check. The local requireLocalEmailVerified gate still applies independently.
disableImplicitLinking: existing linked accounts continue to work; new sign-ins for unlinked emails return account_not_linked instead of auto-linking. Explicit linkSocial() calls from an authenticated session still succeed .
Explicit Linking (linkSocial)#
An authenticated user can link additional providers via authClient.linkSocial({ provider: "google" }) or with an ID token directly (authClient.linkSocial({ provider: "google", idToken: { token: "...", nonce: "..." } })). The callback validates email match (unless allowDifferentEmails: true) before creating the account row. No session is created for explicit links .
Key Source Files#
| File | Purpose |
|---|---|
packages/better-auth/src/oauth2/link-account.ts | handleOAuthUserInfo() and applyUpdateUserInfoOnLink() — all linking logic |
packages/core/src/types/init-options.ts | accountLinking option type definitions and JSDoc |
packages/better-auth/src/oauth2/errors.ts | OAUTH_CALLBACK_ERROR_CODES and redirectOnError() |
| PR #9578 | Added requireLocalEmailVerified gate; context for security rationale |
| Issue #11138 | Canonical user report of the account_not_linked behavior with a trusted provider |