Skip to content
Chapter 53Lesson 8

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:

  1. The user clicks. Your button calls authClient.signIn.social({ provider: 'google' }), and the browser redirects to Google’s consent screen . The library attaches the state value and the PKCE challenge on the way out.
  2. 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.
  3. The library validates state (this is the CSRF defense from the protocol chapter), exchanges the code for tokens using the PKCE verifier, and reads the user’s profile from Google’s userinfo endpoint.
  4. Now the library decides who this is. Call this step the find-or-create lookup.
  5. It issues a session (the cookie rides back out through nextCookies) and redirects the browser to your callbackURL.

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 account row 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 user with 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 fresh account row against that existing user and sign them in. If a match exists but the provider is not trusted, refuse, returning account-not-linked rather 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 user and its account row, with emailVerified set 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.

1 Click
button
2 Google
consent
3 Callback
→ catch-all
4 Find-or-
create
5 Issue
session
6 Land on
callbackURL

The library attaches state + the PKCE challenge on the way out.

Click. Your button calls authClient.signIn.social({ provider: 'google' }), and the browser redirects to Google. Nothing is signed in yet.
1 Click
button
2 Google
consent
3 Callback
→ catch-all
4 Find-or-
create
5 Issue
session
6 Land on
callbackURL

On Google's domain — the user picks an account and approves the scopes.

Consent. The user picks an account and approves the scopes — on Google's domain, not yours. The page you never see.
1 Click
button
2 Google
consent
3 Callback
→ catch-all
4 Find-or-
create
5 Issue
session
6 Land on
callbackURL

The library validates state, swaps code for tokens, reads userinfo.

Callback. Google redirects back to …/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.
1 Click
button
2 Google
consent
3 Callback
→ catch-all
4 Find-or-
create
5 Issue
session
6 Land on
callbackURL
the lookup asks, in order
  • 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
The hinge — find-or-create. Now the library decides who this is. One button, four landing states: it tries the provider identity, then the email, and only refuses to auto-merge on an email match when the provider isn't trusted.
1 Click
button
2 Google
consent
3 Callback
→ catch-all
4 Find-or-
create
5 Issue
session
6 Land on
callbackURL

The cookie rides back out through nextCookies.

Session. The library issues a session and the cookie rides back out through nextCookies. From here on the user is signed in.
1 Click
button
2 Google
consent
3 Callback
→ catch-all
4 Find-or-
create
5 Issue
session
6 Land on
callbackURL

Validated against your trustedOrigins before the redirect fires.

Land. The browser is redirected to your 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.

User clicks the button; authClient.signIn.social redirects the browser to Google
The user approves the requested scopes on Google’s consent screen
Google redirects back to /api/auth/callback/google with a code and state
The library validates state, swaps the code for tokens, and runs the find-or-create lookup
The library issues a session and sets the cookie
The browser lands on the 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.

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.

lib/env.ts
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.

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: {...} }.

1 / 1

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.

  1. Create a project in Google Cloud Console (or pick an existing one).

  2. 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.

  3. Create OAuth client credentials, of type Web application.

  4. Register the Authorized redirect URIs. Add http://localhost:3000/api/auth/callback/google for 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.

  5. Copy the generated client_id and client_secret into that environment’s env. Dev’s credentials go in your local .env, and production’s go in your production secret store.

  6. 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.

With the provider configured, the button itself is almost anticlimactic. One line:

components/google-button.tsx
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
providerId 'google' accountId '108…74' the provider's stable id (the OIDC sub) — safe to log accessToken ya29.… secret idToken eyJ… secret refreshToken only if accessType: 'offline' scope 'openid email profile' expiresAt 2026-… password no secret stored here

A pointer to a Google identity, plus tokens. No secret of its own.

account password sign-up
providerId 'credential' accountId <userId> accessToken idToken refreshToken scope expiresAt password $argon2id$… secret · hashed

One secret — the hash. No provider tokens at all.

user filled from the Google profile
email 'ada@gmail.com' from the profile name 'Ada Lovelace' from name image 'https://…' from picture emailVerified true from email_verified
The OAuth account row is a pointer plus tokens, no password; the credential row is a secret, no tokens. Same table, opposite contents.

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 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_verified as false or absent, depending on the domain’s configuration.
  • Apple serializes it as the string "true", not a boolean, so a naive === true check 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).

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:

lib/auth.ts
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.

Sign in existing user The account row already exists
Create user + account First-time OAuth, no verification email
Link a second account row Onto the existing user
Refuse — account-not-linked Don't auto-merge on an unverified claim
unauthorized No credential row exists for this email — the same opaque result a wrong password returns
A returning user who already signed in with Google last week
A user whose account row already links this exact Google identity
A brand-new email that has never been seen in the database
A first-time social sign-in with an email no user row has
The email matches a password account, and Google is a trusted provider
The email matches a password account, but the provider is not trusted
A Google-only user types their email and a password into the password form

Per-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: clientId plus clientSecret, scopes openid 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 with prompt: '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 canonical provider, already wired above. Two extra knobs worth knowing.

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.

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.

lib/auth.ts
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.

lib/auth.ts
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.

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.

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.