Skip to content
Chapter 53Lesson 8

Social sign-in with OAuth

Add OAuth social sign-in to your Better Auth app, wiring the Google 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 they’re back in your app signed in, no password typed. The smooth part is easy. The questions that decide whether you shipped it right are: what did you configure to make that button work, where did the user’s identity get stored, and what happens the second time they sign in, or when they’d already signed up with a password months ago?

You met the OAuth 2.1 protocol in the auth mental-model chapter, and the [...all] route you mounted already receives the callback, so you won’t write much new code here. What’s left is configuration, a mental model of what the library does the instant the redirect lands, and a few judgment calls that only bite in production.

Hold onto one idea. A password flow stores a secret the user set, the Argon2 hash in account.password. An OAuth flow stores a pointer to an identity the provider owns: which provider vouched for them, that provider’s id for them, and some tokens, but no secret of yours. Signing in stops meaning “this person knew the secret we stored” and becomes “the provider vouched for this identity.” The lookup logic, the awkward UX gap, and whether to keep the tokens at all all follow from that shift.

The callback round-trip and the find-or-create lookup

Section titled “The callback round-trip and the find-or-create lookup”

You wire the button; the library owns everything between the click and the session. That work hides across a redirect to a domain you don’t control, so you need a model of what the library decides when the user returns before the console steps that follow make sense.

The protocol chapter covers the byte-level detail of PKCE and state; here is the round-trip at the altitude that matters:

  1. The user clicks. Your button calls authClient.signIn.social({ provider: 'google' }), and the browser redirects to Google’s consent screen , with the state value and PKCE challenge attached.
  2. The user approves. Google redirects back to …/api/auth/callback/google?code=…&state=…, straight into the catch-all you already mounted. No new route, no handler.
  3. The library validates state (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. The library decides who this is. Call this 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 produces several outcomes. The library runs the lookup in a fixed order:

  • First, by provider identity. Is there an account row linking this exact Google identity to a user? If so, sign them in. This is the common case, every time after the first.
  • Next, by email. Does a user with this email already exist, maybe from a password sign-up months ago? If it does and Google is a trusted provider, link: insert a fresh account row against that user and sign them in. If the provider is not trusted, refuse with account-not-linked rather than fold a Google login into a password account on an email claim nobody verified to you.
  • Otherwise, create one. A new user and account row, emailVerified set from the provider’s claim, signed in. This is first-time-OAuth sign-up: one round-trip, no verification email, because the provider already proved the user owns that inbox.

So one button has three landing states: sign in, link, or create. Which one fires depends on what’s in your database and whether the provider is trusted.

“Trusted” is doing real work, and the default is easy to get backwards. Linking on an email match is on by default, but it is gated on the provider being trusted, and the trusted list has no default: you must name the providers yourself. So until you configure that list (the next lesson’s job), a same-email Google sign-in against an existing password account refuses rather than links. That is the safe default, not a bug.

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.

Google is the year-one default for a web app, since nearly everyone already has the account. Configure it once and the other providers follow the same shape with minor quirks, covered later in this lesson as a reference. Three layers stack in dependency order: the environment variables holding your credentials, the socialProviders block that wires them into the auth instance, and the registration in Google’s console so Google will talk to your app.

Google issues you two values: a client_id and a client_secret. Both go through the validated env schema you set up earlier, 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, staging, and production. 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 in practice: OAuth has no wildcard 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 then fail at boot, where you see it instantly, rather than on a user’s first sign-in three days later.

You add this block to betterAuth({ ... }) in lib/auth.ts, alongside the emailAndPassword and emailVerification options. They are all configuration on the one auth instance.

socialProviders: {
google: {
clientId: env.GOOGLE_CLIENT_ID,
clientSecret: env.GOOGLE_CLIENT_SECRET,
// accessType: 'offline', // only if you call Google's API later
},
},

One entry per provider, keyed by name. google is built in, so unlike the passkey or magic-link plugins, it needs no plugin: 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 credentials, pulled from the validated env object and nowhere else.

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 commented out on purpose: it makes Google issue a refresh token, which you only want if you will call Google’s API on the user’s behalf. 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 the absent redirectURI. It defaults to <baseURL>/api/auth/callback/google, the path your catch-all already serves; set it only if you mounted auth at a non-default path. Adding a second provider is just another key: { google: {...}, github: {...} }.

1 / 1

Students often go looking for this, so to state it plainly: the browser client in lib/auth-client.ts needs no plugin for built-in social providers. Where magic-link, passkey, and 2FA each required a matching client plugin, the social sign-in button calls a method 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 external console, and the part most likely to trip you up. The UI gets re-skinned over time, so what matters is the six things you do, not the exact screens. The live console is 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, what the user sees when they approve. Set the publishing status, a support email, and the scopes you request. (Testing lets only the test users you list sign in; in production lets anyone.) Plain sign-in needs the scopes openid email profile. Anything more sensitive, like Calendar or Drive, triggers Google’s app-review process, so add it only if sign-in 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, plus the staging and production URLs, 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 into your local .env, production’s into your production secret store.

  6. Test the full loop: click the button, approve on the consent screen, land back signed in.

To see the whole loop done once on screen, this short walkthrough wires it end to end.

Console screens get re-skinned, but the concept holds. Bookmark the live sources.

The signIn.social call and the account row it writes

Section titled “The signIn.social call and the account row it writes”

With the provider configured, the button is one line:

components/google-button.tsx
authClient.signIn.social({ provider: 'google', callbackURL: '/dashboard' });

A plain client onClick calls this method, so only the button needs 'use client'; the page around it stays a Server Component.

The callbackURL rides through the whole round-trip and becomes the post-callback redirect. The library validates it against your trustedOrigins, so a hostile value can’t bounce the user off-site. The one gap: if your own code splices a ?next= parameter into that callbackURL, run it through the safeNext guard first, since the library only validates redirects it generates, not values you inject.

The user clicks, approves, and lands on /dashboard. A first-time Google sign-in writes a user row and an account row, and that account row looks nothing like the credential row a password sign-up writes.

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.

The split that matters in the OAuth row: accountId is the provider’s public identifier and is safe to log, but the accessToken and idToken are secrets, so keep them out of your logs.

The empty password column is the whole point: this row stores no secret of its own. Signing the user in isn’t “they proved they knew our secret,” it’s “Google vouched for them, and here’s the pointer.”

The button works and the row fills. Now come four decisions where the code runs perfectly either way, and only judgment, a breach, or a support queue tells you whether you got them right.

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 reads the user’s Google Calendar. The tempting move is to add calendar.readonly to the sign-in config so the token is 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 majority who never touch that feature: now everyone is asked at the door to grant calendar access just to use your app. Conversion drops, trust erodes, and a sensitive scope drags Google’s app-review process onto your whole sign-in.

Instead, let sign-in ask for the basics and let each feature request more incrementally, when the user opts into it, via linkSocial({ provider, scopes }) (the next lesson). The rule: scopes are a conversion-and-trust cost paid by everyone 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, and that is genuinely useful: the OAuth sign-up is the verification, so the user skips the verification email entirely.

But a claim is only 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 treats a verified email as unverified.
  • GitHub may report the email as verified without checking.

The senior call: trust the provider for consumer Google; for mixed or enterprise audiences, treat emailVerified as a claim you may want to re-verify. As the email-verification lesson framed it, emailVerified is the capability floor, not the ceiling: it gates the basics but never replaces the per-action authorization checks that run regardless.

Start with the question most people skip: do you even need to keep the provider tokens?

For pure sign-in, no. The tokens land in the account row at callback and are never read again, because your app trusts its own session cookie, not Google’s accessToken, to know who’s signed in.

For products that later call the provider’s API, to read a calendar or push a file to 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. Keep tokens only when you actually use them.

And when you do keep them, one default protects no one until you flip it:

lib/auth.ts
account: {
encryptOAuthTokens: true,
},

This is the same shape as the chapter’s other dangerous defaults: the version that runs perfectly and protects no one is the one to watch for.

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 was ever created. 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 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. Secure, but baffling to the user: they have an account, they just don’t sign in this way, and the form says their credentials are wrong.

Better Auth won’t fix this, and staying opaque is the correct default. But you can do better on purpose. On an 'unauthorized' result, check whether that email has any 'credential' account row; if it has only an OAuth account, surface something honest, like “This email signs in with Google,” with a Google button right there.

Name the trade-off, because it’s real: that friendly branch leaks that an account exists for the email, the same 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, 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. Keep the default opaque, and make the friendly branch a conscious choice.

Now classify each scenario. The pair that separates understanding from guessing is the two “email matches” cases: trusted versus untrusted provider.

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

You configure Google once; every other provider is the same socialProviders shape with its own quirks. Jump to a tab when you wire that provider up.

  • The core setup: clientId and clientSecret, scopes openid email profile, default redirect URI.
  • prompt: 'select_account' forces the account-picker on every sign-in, handy when users juggle multiple Google accounts.
  • accessType: 'offline' with prompt: 'select_account consent' yields a refresh token. It only matters if you store and use tokens (see the token-persistence section).
  • Consent-screen publishing: testing lets only your listed test users sign in; production lets anyone in, but sensitive scopes need Google’s review.
The provider wired above, plus two optional knobs.

For a provider Better Auth doesn’t ship built in, the genericOAuth() plugin takes the same config shape, except you supply the authorize, token, and userinfo URLs yourself.

When a brand-new user signs up, OAuth users included, where does the welcome email fire? You built that send path earlier in the course; here is where it plugs in.

There is no per-provider success callback, no socialProviders.google.onSuccess. Side-effects ride global hooks instead, through two seams.

The first seam, mapProfileToUser, runs before the user row is created and remaps profile fields, for example splitting the provider’s single name into firstName and lastName.

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 fires the welcome email. databaseHooks.user.create.after is global and runs once, when a brand-new user row is created, which for OAuth means a first-time sign-up. Call the Resend sendEmail pipeline you built earlier here.

lib/auth.ts
databaseHooks: {
user: {
create: {
after: async (user) => {
void sendWelcomeEmail(user.email);
},
},
},
},

The row insert and session were already issued before this hook runs, so a slow or throwing hook must not block sign-in. The void fires the email and forgets it, instead of awaiting it into the critical path. Keep these hooks small and side-effecting only: kick off the email, record the analytics event, and return.

Two threads are left open here. The next lesson covers account linking: which providers you trust, what “link on email match” does once configured, and the explicit link and unlink flows from a settings page. Calling a provider’s API with the tokens you stored, including refresh and scope-on-demand, sits outside Better Auth, so reach for it only when a feature needs it.

A third thread sits one tier up. Everything in this lesson is consumer social sign-in, where the user signs in with a Google, GitHub, or Microsoft account they personally own; enterprise SSO is a different shape, where a customer’s IT department points its whole company at the company’s own identity provider (“log in with our Okta” or “our Entra ID”) via SAML 2.0 or OIDC. Better Auth covers it with a separate first-party plugin, @better-auth/sso (the sso() plugin), but you only reach for it when a B2B prospect makes “sign in with our IdP” a procurement requirement.

Two of this lesson’s senior calls have well-documented sources worth keeping open.