Social sign-in with OAuth
Add social sign-in to your Better Auth app with OAuth providers like Google, wiring the button, the callback's find-or-create lookup, and the identity that lands in the account table.
A user clicks Sign in with Google, a Google screen flashes by, and a second later they’re back in your app, signed in, no password ever typed. From the outside it’s the smoothest sign-in you can offer. From the inside, three questions decide whether you’ve shipped it right: what did you have to configure for that button to work, where did the user’s identity get stored, and (the part that generates support tickets) what happens the second time they sign in, or when they’d already signed up with a password back in January?
You’ve already met the machinery. In the chapter on the auth mental model, you learned the OAuth 2.1 protocol: the redirect to the provider, the PKCE challenge, the state parameter, the exact-match redirect URIs, all of which describe how the bytes move between your app and Google. This lesson turns that protocol into a button, and you won’t write much new code to do it. The [...all] catch-all you mounted in the previous chapter already receives the callback, and Better Auth already owns every byte of the round-trip. What’s left is configuration, a mental model of what the library does the instant the redirect lands, and a handful of judgment calls that bite only in production.
One idea carries the whole lesson, so hold onto it. A password flow stores a secret the user set: the Argon2 hash sitting in account.password. An OAuth flow instead stores a pointer to an identity the provider owns: no password, just which provider vouched for them, that provider’s id for them, and some tokens. Signing in stops being “this person knew the secret we stored” and becomes “the provider vouched for this identity.” The lookup logic, the awkward UX gap, and the decision about whether to keep the tokens at all are all consequences of that shift.
By the end you’ll have a working Google button, the right redirect URI registered for each environment, and a clear view of the find-or-create lookup, the scopes you charge every user at sign-in, and the OAuth-only-account gap that confuses real people.
What the library does when the redirect lands
Section titled “What the library does when the redirect lands”Here is the division of labor: you wire the button, and the library owns everything between the click and the session. That work is invisible, because it happens across a redirect to a domain you don’t control and back. So before you touch a single console screen, you need a model of what the library decides when the user comes back. The console steps that follow only make sense once you know what they’re in service of.
Walk the round-trip. The protocol chapter covers the byte-level detail of PKCE and state, so this is the altitude that matters here:
- The user clicks. Your button calls
authClient.signIn.social({ provider: 'google' }), and the browser redirects to Google’s consent screen . The library attaches thestatevalue and the PKCE challenge on the way out. - The user approves. Google redirects back to
…/api/auth/callback/google?code=…&state=…, straight into the catch-all you already mounted. There is no new route and no handler to write. - The library validates
state(this is the CSRF defense from the protocol chapter), exchanges thecodefor tokens using the PKCE verifier, and reads the user’s profile from Google’suserinfoendpoint. - Now the library decides who this is. Call this step the find-or-create lookup.
- It issues a session (the cookie rides back out through
nextCookies) and redirects the browser to yourcallbackURL.
Step 4 is where one button quietly produces several different outcomes. The library runs the lookup in a fixed order:
- First, look up by provider identity. Is there already an
accountrow linking this exact Google identity to a user? If so, this is a returning user, so sign them in. This is the common case, the one that fires every time after the first. - If not, look up by email. Does a
userwith this email already exist? Maybe they signed up with a password months ago. If a match exists and Google is a trusted provider, link: insert a freshaccountrow against that existing user and sign them in. If a match exists but the provider is not trusted, refuse, returningaccount-not-linkedrather than silently folding a Google login into a password account on the strength of an email claim nobody verified to you. - If there’s no user at all, create one. A new
userand itsaccountrow, withemailVerifiedset from what the provider claims, and sign them in. This is first-time-OAuth sign-up: one round-trip, no separate verification email, because the provider already proved the user owns that inbox.
So the same button has three landing states: sign in an existing user, link to an existing user, or create a brand-new one. Which one fires depends on what’s already in your database and whether the provider is trusted.
The word “trusted” is doing real work here, and the default is easy to get backwards, so pin it down. Linking on email match is on by default, but it is gated on the provider being trusted, and the list of trusted providers has no default. You have to name them. So until you configure that list (which is the next lesson’s job), a same-email Google sign-in against an existing password account will refuse, not link. That isn’t a bug, it’s the safe default. The next lesson owns the configuration of which providers you trust and the explicit link-and-unlink flows. Here, you only need the lookup order and the knowledge that the safe default is to refuse to auto-merge.
button
consent
→ catch-all
create
session
callbackURL
The library attaches state + the PKCE challenge on the way out.
authClient.signIn.social({ provider: 'google' }),
and the browser redirects to Google. Nothing is signed in yet.
button
consent
→ catch-all
create
session
callbackURL
On Google's domain — the user picks an account and approves the scopes.
button
consent
→ catch-all
create
session
callbackURL
The library validates state, swaps code for tokens, reads userinfo.
…/api/auth/callback/google — straight into the catch-all
you already mounted. No new route. The library validates state, swaps code for tokens, and reads userinfo.
button
consent
→ catch-all
create
session
callbackURL
- account row exists Sign in existing user
- email matches, provider trusted Link a new account row
- email matches, provider untrusted Refuse — account-not-linked
- no user at all Create user + account
button
consent
→ catch-all
create
session
callbackURL
The cookie rides back out through nextCookies.
nextCookies. From here on the user
is signed in.
button
consent
→ catch-all
create
session
callbackURL
Validated against your trustedOrigins before the redirect fires.
callbackURL, already signed in. No password was stored anywhere. The provider's vouch
is the credential — the account row points at a Google
identity, not a secret you keep.
Lock the order in before moving to configuration. The following short exercise asks you to drag the six round-trip steps into sequence.
Order the steps of one social sign-in round-trip, from the click to landing back in the app. Drag the items into the correct order, then press Check.
authClient.signIn.social redirects the browser to Google /api/auth/callback/google with a code and state state, swaps the code for tokens, and runs the find-or-create lookup callbackURL Configuring Google as the canonical provider
Section titled “Configuring Google as the canonical provider”Google is the year-one default for a SaaS, since it’s the account almost everyone already has. Configure it once, well, and the other providers are the same shape with a few quirks (those come later in this lesson, as a reference you scan, not a tutorial you read).
There are three layers, and they stack in dependency order: the environment variables that hold your credentials, the socialProviders block that wires them into the auth instance, and the registration you do in Google’s own console so Google agrees to talk to you.
Layer one: the credentials in env.ts
Section titled “Layer one: the credentials in env.ts”Google issues you two values for your app: a client_id and a client_secret. They go through the same validated env schema you set up earlier in the course, never read straight off process.env.
const serverSchema = z.object({ GOOGLE_CLIENT_ID: z.string().min(1), GOOGLE_CLIENT_SECRET: z.string().min(1), // …the rest of your server-side variables});Two rules ride on those two lines.
Use separate credentials per environment. Register a distinct OAuth client for dev, for staging, and for production in the Google console. A leaked staging secret then never touches production, and each environment carries its own redirect URI. This is the exact-match redirect rule from the protocol chapter put into practice: there are no wildcards in OAuth redirect URIs, so each environment registers its own exact URL.
Read through the validated env object, never process.env.GOOGLE_* directly. A missing secret should fail at boot, the moment the app starts, where you’ll see it instantly, rather than on a user’s first sign-in attempt three days later.
Layer two: the socialProviders block
Section titled “Layer two: the socialProviders block”This is the block you add to betterAuth({ ... }) in lib/auth.ts, right alongside the emailAndPassword and emailVerification options you’ve already configured. They’re siblings, all configuration on the one auth instance. Step through it.
socialProviders: { google: { clientId: env.GOOGLE_CLIENT_ID, clientSecret: env.GOOGLE_CLIENT_SECRET, // accessType: 'offline', // only if you call Google's API later },},The socialProviders block holds one entry per provider, keyed by name. google is built in, so you don’t install a plugin for it. Unlike the passkey or magic-link plugins from earlier lessons, built-in social providers are plain config.
socialProviders: { google: { clientId: env.GOOGLE_CLIENT_ID, clientSecret: env.GOOGLE_CLIENT_SECRET, // accessType: 'offline', // only if you call Google's API later },},The two credentials, pulled from the validated env object. This is the only place they’re read, never off process.env directly.
socialProviders: { google: { clientId: env.GOOGLE_CLIENT_ID, clientSecret: env.GOOGLE_CLIENT_SECRET, // accessType: 'offline', // only if you call Google's API later },},accessType: 'offline' is shown commented out on purpose. It’s the knob that makes Google issue a refresh token, and you only want it if you’ll call Google’s API on the user’s behalf later. For plain sign-in, leave it off. More on this in the token-persistence section below.
socialProviders: { google: { clientId: env.GOOGLE_CLIENT_ID, clientSecret: env.GOOGLE_CLIENT_SECRET, // accessType: 'offline', // only if you call Google's API later },},Notice what’s absent: there’s no redirectURI. It defaults to <baseURL>/api/auth/callback/google, exactly the path your catch-all already serves. Only set it if you mounted auth at a non-default path; otherwise an override here is at best redundant and at worst wrong. Adding a second provider is just another key: { google: {...}, github: {...} }.
One thing is worth stating plainly, because students go looking for it: the browser client in lib/auth-client.ts needs no extra plugin for built-in social providers. There’s no socialClient() to add. The magic-link, passkey, and 2FA flows from earlier lessons each needed a matching client plugin; social sign-in does not. The button calls a method that’s already on the client.
Layer three: registering the app in Google Cloud Console
Section titled “Layer three: registering the app in Google Cloud Console”The first two layers are code. The third is a procedure you click through in Google’s console: external, occasionally re-skinned, and the part most likely to trip you up. The console UI shifts over time, so the goal here is the six things you do, not a pixel-by-pixel tour. The live console is always the source of truth, and the cards at the end of this section link straight to it.
-
Create a project in Google Cloud Console (or pick an existing one).
-
Configure the OAuth consent screen. This is what the user sees when they approve. Set the publishing status, a support email, and the scopes you request. (Testing means only the test users you list can sign in; in production means anyone can.) For plain sign-in, the scopes are
openid email profile. Anything more sensitive, such as Calendar or Drive, triggers a Google app-review process, so don’t add it here unless sign-in itself genuinely needs it. -
Create OAuth client credentials, of type Web application.
-
Register the Authorized redirect URIs. Add
http://localhost:3000/api/auth/callback/googlefor local dev, and the staging and production URLs alongside it, each one exact. This is the single most common way an OAuth setup fails, so it gets its own warning below. -
Copy the generated
client_idandclient_secretinto that environment’senv. Dev’s credentials go in your local.env, and production’s go in your production secret store. -
Test the full loop end to end: click the button, approve on the consent screen, land back signed in.
If you’d find it grounding to watch the whole loop done once on screen, this short walkthrough wires it end to end.
Console screens get re-skinned, but the concept doesn’t. Bookmark the live sources.
Every Google-specific option the socialProviders block accepts, kept current with the library.
The console walkthrough for the consent screen and Web application credentials.
The button and what lands in account
Section titled “The button and what lands in account”With the provider configured, the button itself is almost anticlimactic. One line:
authClient.signIn.social({ provider: 'google', callbackURL: '/dashboard' });That’s the whole client-side surface. The button is a plain client onClick calling this method, and the page around it can stay a Server Component, with only the button itself marked 'use client'.
If you’ve been following the password flows earlier in this chapter, you’ll notice something missing, and the absence is deliberate.
The callbackURL you pass rides through the whole round-trip and becomes the post-callback redirect. The library validates it against your configured trustedOrigins, so a hostile value can’t bounce the user off-site. One case needs your own care: if your surrounding code ever reflects a ?next= parameter into that callbackURL, route it through the safeNext guard from the password-reset lesson first, because the library validates its own redirects, not values your code splices in.
So the user clicks, approves, and lands on /dashboard. What’s in the database now? This is where the password-versus-pointer contrast becomes something you can see. After a first-time Google sign-in, you get a user row and an account row, and the account row looks nothing like the password one.
The following two row-cards show it. Compare the OAuth account row with the 'credential' row a password sign-up produces.
account Google sign-in A pointer to a Google identity, plus tokens. No secret of its own.
account password sign-up One secret — the hash. No provider tokens at all.
user filled from the Google profile Read the contrast. The credential row stores a secret, the Argon2 hash, and nothing else of note. The Google row stores no secret at all. It holds providerId and accountId , the pointer to a Google identity, plus the tokens Google handed back: an accessToken , an idToken , and a refreshToken only if you set accessType: 'offline'. Mind the line between them: accountId is the provider’s public-ish identifier and is safe to log, but the three tokens are secrets, so keep them out of your logs.
That empty password column is the point made literal: this account row has no secret of its own. Signing this user in isn’t “they proved they knew our secret,” it’s “Google vouched for them, and here’s the pointer to prove it.”
The senior calls that bite in production
Section titled “The senior calls that bite in production”The button works and the row fills. Now comes the part the happy path hides: four decisions where the code runs perfectly either way, and only judgment (or a breach, or a support queue) tells you whether you got them right. These are the calls juniors get wrong because nothing crashes when they do.
Scopes: least privilege, charged to every user at sign-in
Section titled “Scopes: least privilege, charged to every user at sign-in”Plain sign-in needs exactly three scopes: openid email profile. The trap is reaching for more.
Say a feature in your app reads the user’s Google Calendar. The tempting move is to add calendar.readonly to the sign-in config so the token’s already there when they reach the feature. Don’t. A scope on the sign-in config goes on the consent screen for every user at sign-in, including the large majority who never touch that feature. Now everyone is asked, at the door, to grant calendar access just to use your app at all. Conversion drops, trust erodes, and a sensitive scope drags Google’s app-review process onto your whole sign-in.
The pattern that scales is to let sign-in ask for the basics and let a feature that needs more request it incrementally, at the moment the user opts into that feature, via linkSocial({ provider, scopes }), which the next lesson covers. The rule to carry: scopes are a conversion-and-trust cost paid by everyone, charged at sign-in, so only ask for what sign-in itself needs.
email_verified: the provider’s claim is an input, not a guarantee
Section titled “email_verified: the provider’s claim is an input, not a guarantee”When the library creates a user from an OAuth sign-in, it sets user.emailVerified from the provider’s email_verified claim. For a consumer Google account that claim is true, which is genuinely useful: the OAuth sign-up is the verification, and the user skips the verification email entirely, because the provider already proved they own the inbox.
But the provider’s claim is exactly that, a claim, and it isn’t uniform across providers:
- Google Workspace accounts may report
email_verifiedas false or absent, depending on the domain’s configuration. - Apple serializes it as the string
"true", not a boolean, so a naive=== truecheck silently fails and treats a verified email as unverified. - GitHub may assume the email is verified without actually checking.
The senior call: trust the provider for consumer Google, and for mixed or enterprise audiences, treat emailVerified as a claim you may want to re-verify on top. Remember the framing from the email-verification lesson, too: emailVerified is the capability floor, not the ceiling. It gates the basics, but it never replaces the per-action authorization checks that run regardless (the org-and-roles work later in the course).
Token persistence and encryptOAuthTokens
Section titled “Token persistence and encryptOAuthTokens”Start with the question most people skip: do you even need to keep the provider tokens?
For pure sign-in, you don’t. The tokens land in the account row at callback and are never read again. Your app trusts its own session cookie, not Google’s accessToken, to know who’s signed in. The tokens just sit there.
For products that call the provider’s API later, to read the user’s calendar or push a file to their Drive, the tokens earn their keep: they’re read on demand and refreshed via the refreshToken when they expire. That is why the accessType: 'offline' knob exists; without it Google issues no refresh token. The rule: keep tokens only when you actually use them.
And when you do keep them, here is the hinge, the one default in this lesson that protects no one until you flip it:
account: { encryptOAuthTokens: true,},This is the same shape as the dangerous defaults from earlier in the chapter, the password-reset session revocation and the hashed reset token. The version that runs perfectly and protects no one is the one to watch for. Flip it on whenever tokens are persisted and used.
The OAuth-only account: the mistype that fills your support queue
Section titled “The OAuth-only account: the mistype that fills your support queue”This is the most product-relevant point in the lesson, and it follows straight from the password-versus-pointer contrast.
A user who signed up with Google has no 'credential' account row. No password exists anywhere, because there was never one to create. Weeks later they come back, forget they used Google, and type their email and a made-up password into your password form. What happens?
The library does exactly the right thing: the sign-in action returns the same opaque 'unauthorized' Result as a genuinely wrong password. That’s mapSignInError collapsing the library’s INVALID_EMAIL_OR_PASSWORD into one shape, the enumeration discipline from the sign-in lesson holding the line. From the security side, this is perfect. From the user’s side, it’s baffling: they have an account, they just don’t sign in this way, and the form is telling them their credentials are wrong.
Better Auth won’t fix this for you, and the default of staying opaque is the correct floor. But you can do better, on purpose. On an 'unauthorized' result, you can check whether that email has any 'credential' account row. If it has only an OAuth account, surface something honest: “This email signs in with Google,” with a Google button right there.
Name the trade-off out loud, because it’s a real one. That friendly branch does leak that an account exists for the email, which is exactly the enumeration cost the chapter has been guarding against. The point isn’t that leaking is fine; it’s that this is a deliberate trade of support cost against enumeration cost, decided on purpose, the same shape as the “this email is already registered” call from the sign-up lesson. It costs one extra query and pays for itself in tickets you never receive. Make the default opaque, and make the friendly branch a conscious choice, not an accident.
Now the keystone exercise. The single most-misread idea in this lesson is that one button has multiple landing states, and that the password form has a confusing failure for OAuth users. The following classification drill makes you commit to which outcome each scenario produces. The two “email matches” cases, trusted versus untrusted provider, are the pair that separates understanding from guessing.
For each scenario, pick the outcome Better Auth produces — either at the OAuth callback or at the password form. Drag each item into the bucket it belongs to, then press Check.
account row already links this exact Google identityuser row hasPer-provider quirks: a reference, not a tutorial
Section titled “Per-provider quirks: a reference, not a tutorial”You configure Google once. Every other provider is the same socialProviders shape with its own handful of gotchas, and those gotchas are what this section is for. Don’t read it front to back; treat the following tabs as a reference you jump to when you’re actually wiring up “Sign in with GitHub” and want a quick list of what’s different.
- The core of this lesson:
clientIdplusclientSecret, scopesopenid email profile, default redirect URI. prompt: 'select_account'forces the account-picker on every sign-in, useful when your users juggle multiple Google accounts.accessType: 'offline'paired withprompt: 'select_account consent'is the combination that yields a refresh token, and it only matters if you store and use tokens (see the token-persistence section).- Consent-screen publishing: testing means only your listed test users can sign in; in production means anyone can, but sensitive scopes need Google’s review.
- The catch: the default scope returns the public profile only, so
user.emailcan benullwhen the user keeps their email private. - Reaching for the
user:emailscope lets you query their emails, butGET /userstill returnsnullfor a private address. The primary verified address lives at a separate endpoint,/user/emails. - Never assume
user.emailis non-null on a fresh GitHub sign-up. A null email breaks every downstream surface that keys on it: the welcome email, the account lookup, anything.
- Apple returns the user’s email and name only on the very first sign-in. Every sign-in after returns just the
sub. The app must persist the email at first sight, because Apple has nouserinfoendpoint to re-fetch it. (Better Auth stores it on that first sign-in; trust that storage afterward.) - Requesting name and email requires
responseMode: 'form_post', so the callback arrives as aPOST, not aGET. Your catch-all handles this transparently. email_verifiedandis_private_emailarrive as strings ("true"), not booleans, the same=== truecatch called out in theemail_verifiedsection.
- The
tenantparameter picks the audience:consumers(personal Microsoft accounts),organizations(work or school accounts), orcommon(both). - B2B SaaS picks
organizations; a consumer product pickscommon. - Everything else is the same
socialProvidersshape, so naming the tenant is the only real decision.
One last pointer. If you ever need a provider Better Auth doesn’t ship built in, the genericOAuth() plugin covers it: the same config shape, except you supply the authorize, token, and userinfo URLs yourself. It’s a sibling of everything here, not something to learn now.
Post-callback side-effects: done right
Section titled “Post-callback side-effects: done right”One question the chapter has been building toward: when a brand-new user signs up, including a first-time OAuth user, where does the welcome email fire? You built that send path earlier in the course, and this is where it plugs in.
A natural guess is a per-provider success callback. There isn’t one; there’s no socialProviders.google.onSuccess. Side-effects after sign-in ride global hooks instead, and there are two seams worth knowing.
The first seam, mapProfileToUser, runs before the user row is created and lets you remap profile fields, for example splitting the provider’s single name into firstName and lastName, or pulling a field the default mapping skips.
google: { clientId: env.GOOGLE_CLIENT_ID, clientSecret: env.GOOGLE_CLIENT_SECRET, mapProfileToUser: (profile) => ({ firstName: profile.given_name, lastName: profile.family_name, }),},The second seam is the welcome-email seam. databaseHooks.user.create.after is global and fires once, when a brand-new user row is created, which for OAuth means first-time-OAuth sign-up. This is where you call the sendEmail pipeline you built earlier in the course, when you wired up the welcome email through Resend.
databaseHooks: { user: { create: { after: async (user) => { void sendWelcomeEmail(user.email); }, }, },},There’s a discipline here. The row insert and the session were already issued before this hook runs, so a slow or throwing hook must not block sign-in. Note the void in the snippet above: the email is fired and forgotten, not awaited into the critical path. Keep these hooks small and side-effecting only: kick off the email, record the analytics event, and return. The user is already signed in, so don’t make them wait on your follow-up work.
Where to go next
Section titled “Where to go next”You’ve shipped a working Google button, registered the redirect URI per environment, and, more importantly, you can now answer all three of the opening questions. You know what you configured (env, the socialProviders block, the console), where the identity got stored (an account row that’s a pointer plus tokens, no password), and what happens the second time (the find-or-create lookup picks one of three landing states).
Two threads are deliberately left open. The next lesson owns account linking: which providers you trust, what “link on email match” really does once you configure it, and the explicit link-and-unlink flows from a settings page. And calling a provider’s API with the tokens you stored, including token refresh and scope-on-demand, sits outside Better Auth’s job, so reach for it only when a feature genuinely needs it.
A third thread sits one tier up, and it is worth naming so you recognize it when a deal raises it.
Everything in this lesson is consumer social sign-in: the user signs in with a Google, GitHub, or Microsoft account they personally own.
Enterprise SSO is a different shape: a customer’s IT department wants their whole company to sign in against the company’s own identity provider, “log in with our Okta” or “with our Entra ID,” so that provisioning and deprovisioning stay in the customer’s hands, not yours.
That is the organizations-tenant world the Microsoft tab gestured at, taken to its conclusion: SAML 2.0 or OIDC brokered against an IdP the customer controls, not a social account.
Better Auth covers it with a separate first-party plugin, @better-auth/sso (the sso() plugin), distinct from the socialProviders block here and from genericOAuth().
You reach for it the first time a B2B prospect makes “sign in with our IdP” a procurement requirement; until then it is a named reach, not year-one work.
The full social-provider reference: every built-in provider, the shared options, and genericOAuth for the rest.
The null-email caveat and the user:email scope, straight from the source.
External resources
Section titled “External resources”Two of this lesson’s senior calls have well-documented sources worth keeping open. The scope-minimization rule comes straight from Google’s own guidance, and the “refuse to auto-merge on an unverified claim” default is the textbook defense against a real, named attack.