OAuth Authentication Flow#
Better Auth routes all social/OAuth provider responses through a single callback endpoint — /callback/:id — that handles both GET and POST requests. POST requests are immediately redirected to GET to ensure cookies are set .
Every callback follows these steps before branching:
- Parse state — extracts
callbackURL,link,errorURL,newUserURL,requestSignUp, andidTokenNoncefrom the signed state cookie . State is parsed before provider errors are checked, so the storederrorURLis always available for redirects. - Exchange code for tokens — calls
provider.validateAuthorizationCode(). - Fetch user profile — calls
provider.getUserInfo(). - Branch: explicit linking (if
linkflag is set in state) vs. sign-in/registration.
Flow Diagram#
OAuth Provider → /callback/:id
│
┌───────┴────────┐
link flag? No link
│ │
[linkOAuthAccount()] [handleOAuthUserInfo()]
No session created │
┌───────────┴───────────┐
accountOwner found? No user found
│ │
┌─────────┴──────────┐ disableSignUp?
account linked? email match? │ │
│ │ [Error] [Register]
[Sign-In] [Implicit Link]
(or blocked)
Path 1: Explicit Account Linking (link flag)#
When the state carries a link payload (set by authClient.linkSocial()), linkOAuthAccount() is called:
- Validates that the provider is trusted or the IdP email is verified
- Validates that emails match, unless
account.accountLinking.allowDifferentEmailsistrue - If the account row already exists, updates its tokens; otherwise creates a new account row via
internalAdapter.createAccount() - Optionally syncs provider profile to the user via
applyUpdateUserInfoOnLink()whenaccountLinking.updateUserInfoOnLinkistrue - Redirects to
callbackURL— no session is created
Path 2 & 3: Sign-In / Registration via handleOAuthUserInfo()#
For non-link callbacks, handleOAuthUserInfo() drives the remaining branching.
User Lookup#
The function first calls internalAdapter.findAccountOwnerByKey({ providerId, accountId }) :
owned— account row found; returns the linked user directlyorphaned— account row exists but user row is missing; returns an error immediately- No account match — falls back to email lookup via
findUserByEmail() - Nothing found →
isRegister = true
Sign-In Path (account already linked)#
When the account is already linked (linkedAccount is set), the handler:
- Calls
assertValidUserInfo()withaction: "sign-in"— allowing plugins to reject sign-ins - Refreshes tokens on the account row unless
account.updateAccountOnSignInisfalse; note: scope is intentionally not updated on sign-in - Promotes
emailVerified: truefrom the IdP to the local row if the local row was unverified - If
overrideUserInfoOnSignInis set on the provider, overwritesname,image, and mapped profile fields — but never rebindsemailoremailVerifiedidentity
Implicit Linking Path (email match, no account row)#
When a user is found by email but no account row exists for the (providerId, accountId) pair, implicit linking runs — or is blocked :
| Condition | Config key |
|---|---|
Provider is not trusted and IdP emailVerified is false | account.accountLinking.trustedProviders |
Local user has emailVerified: false | account.accountLinking.requireLocalEmailVerified (default true, marked deprecated) |
| Account linking is disabled | account.accountLinking.enabled = false |
| Implicit linking is disabled | account.accountLinking.disableImplicitLinking = true |
When blocked, the handler returns { error: "account not linked" } and the callback redirects with ?error=account_not_linked. When all conditions pass, internalAdapter.linkAccount() creates the account row , and applyUpdateUserInfoOnLink() optionally copies profile fields if updateUserInfoOnLink: true .
Registration Path (isRegister = true)#
If disableSignUp is true (from provider.options.disableSignUp or provider.disableImplicitSignUp && !requestSignUp), returns { error: "signup disabled" } .
Otherwise, both the user row and account row are created atomically inside a transaction via runWithTransaction . If emailVerification.sendOnSignUp is enabled and the new user's email is unverified, a verification email is dispatched in the background .
Hook Execution Differences#
Database hooks (databaseHooks) fire at different points depending on the path. All .after hooks run post-transaction via queueAfterTransactionHook .
| Path | Hooks fired |
|---|---|
| Registration | user.create.before, user.create.after, account.create.before, account.create.after |
| Sign-in (linked account) | account.update.before, account.update.after (token refresh) |
| Implicit account link | account.create.before, account.create.after |
| Explicit link (new) | account.create.before, account.create.after |
| Explicit link (existing) | account.update.before, account.update.after |
create.before returns false to abort (returning null) or { data } to merge additional fields .
Session Creation and Redirect#
After handleOAuthUserInfo() returns successfully, a session is created via internalAdapter.createSession(user.id) and the session cookie is set . Redirect destination depends on isRegister :
- New user →
newUserURL(if configured), elsecallbackURL - Returning user →
callbackURL
Explicit linking skips session creation entirely and redirects immediately to callbackURL .
Generic OAuth Plugin#
The genericOAuth plugin registers custom OAuth/OIDC providers as first-class social providers by prepending them to ctx.socialProviders during init . This means they share the same /callback/:id core endpoint — no plugin-specific routes are needed. The plugin supports OIDC discovery (discoveryUrl), custom token exchange (getToken), PKCE, and multiple tokenEndpointAuth methods (client_secret_post, client_secret_basic, private_key_jwt, none) .
Key Source Files#
| File | Purpose |
|---|---|
src/api/routes/callback.ts | HTTP endpoint, token exchange, flow branching |
src/oauth2/link-account.ts | handleOAuthUserInfo(), linkOAuthAccount(), applyUpdateUserInfoOnLink() |
src/db/with-hooks.ts | createWithHooks, updateWithHooks — hook execution layer |
src/plugins/generic-oauth/index.ts | genericOAuth plugin — registers custom providers |