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:
- The user clicks. Your button calls
authClient.signIn.social({ provider: 'google' }), and the browser redirects to Google’s consent screen , with thestatevalue and PKCE challenge attached. - 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. - The library validates
state(the CSRF defense from the protocol chapter), exchanges thecodefor tokens using the PKCE verifier, and reads the user’s profile from Google’suserinfoendpoint. - The library decides who this is. Call this 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 produces several outcomes. The library runs the lookup in a fixed order:
- First, by provider identity. Is there an
accountrow 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
userwith this email already exist, maybe from a password sign-up months ago? If it does and Google is a trusted provider, link: insert a freshaccountrow against that user and sign them in. If the provider is not trusted, refuse withaccount-not-linkedrather than fold a Google login into a password account on an email claim nobody verified to you. - Otherwise, create one. A new
userandaccountrow,emailVerifiedset 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.
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.
Configuring Google sign-in
Section titled “Configuring Google sign-in”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.
Layer one: the credentials in env.ts
Section titled “Layer one: the credentials in env.ts”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.
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.
Layer two: the socialProviders block
Section titled “Layer two: the socialProviders block”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: {...} }.
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.
-
Create a project in Google Cloud Console, or pick an existing one.
-
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. -
Create OAuth client credentials of type Web application.
-
Register the Authorized redirect URIs. Add
http://localhost:3000/api/auth/callback/googlefor 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. -
Copy the generated
client_idandclient_secretinto that environment’senv: dev’s into your local.env, production’s into your production secret store. -
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.
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 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:
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 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 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 senior calls that bite in production
Section titled “The senior calls that bite in production”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_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 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.
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, 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:
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.
account row already links this exact Google identityuser row hasPer-provider quirks reference
Section titled “Per-provider quirks reference”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:
clientIdandclientSecret, scopesopenid 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'withprompt: '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 default scope returns the public profile only, so
user.emailisnullwhen the user keeps their email private. - The
user:emailscope lets you query emails, butGET /userstill returnsnullfor a private address; the primary verified address lives at/user/emails. - Never assume
user.emailis non-null on a fresh GitHub sign-up. A null email breaks everything that keys on it: the welcome email, the account lookup, all of it.
- Apple returns the email and name only on the first sign-in; every later sign-in returns just the
sub. There is nouserinfoendpoint to re-fetch them, so persist the email at first sight. (Better Auth stores it for you; trust that storage afterward.) - Requesting name and email needs
responseMode: 'form_post', so the callback arrives as aPOST. Your catch-all handles this transparently. email_verifiedandis_private_emailarrive as strings ("true"), not booleans, the same=== truetrap from theemail_verifiedsection.
- The
tenantparameter picks the audience:consumers(personal accounts),organizations(work or school accounts), orcommon(both). - B2B SaaS picks
organizations; a consumer product pickscommon. Everything else is the standardsocialProvidersshape.
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.
Side-effects after sign-in
Section titled “Side-effects after sign-in”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.
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.
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.
Where to go next
Section titled “Where to go next”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.
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.