docs/content/docs/concepts/oauth.mdx
Better Auth comes with built-in support for OAuth 2.0 and OpenID Connect. This allows you to authenticate users via popular OAuth providers like Google, Facebook, GitHub, and more.
If your desired provider isn't directly supported, you can use the Generic OAuth Plugin for custom integrations.
To enable a social provider, you need to provide clientId and clientSecret for the provider.
Here's an example of how to configure Google as a provider:
import { betterAuth } from "better-auth";
export const auth = betterAuth({
// Other configurations...
socialProviders: {
google: {
clientId: "YOUR_GOOGLE_CLIENT_ID",
clientSecret: "YOUR_GOOGLE_CLIENT_SECRET",
},
},
});
To sign in with a social provider, you can use the signIn.social function with the authClient or auth.api for server-side usage.
// client-side usage
await authClient.signIn.social({
provider: "google", // or any other provider id
})
// server-side usage
await auth.api.signInSocial({
body: {
provider: "google", // or any other provider id
},
});
To link an account to a social provider, you can use the linkAccount function with the authClient or auth.api for server-side usage.
await authClient.linkSocial({
provider: "google", // or any other provider id
})
server-side usage:
await auth.api.linkSocialAccount({
body: {
provider: "google", // or any other provider id
},
headers: await headers() // headers containing the user's session token
});
To get the access token for a social provider, you can use the getAccessToken function with the authClient or auth.api for server-side usage. When you use this endpoint, if the access token is expired, it will be refreshed.
const { accessToken } = await authClient.getAccessToken({
providerId: "google", // or any other provider id
accountId: "accountId", // optional, if you want to get the access token for a specific account
})
server-side usage:
await auth.api.getAccessToken({
body: {
providerId: "google", // or any other provider id
accountId: "accountId", // optional, if you want to get the access token for a specific account
userId: "userId", // optional, if you don't provide headers with authenticated token
},
headers: await headers() // headers containing the user's session token
});
To get provider specific account info you can use the accountInfo function with the authClient or auth.api for server-side usage.
const info = await authClient.accountInfo({
query: { accountId: "accountId" }, // here you pass in the provider given account id, the provider is automatically detected from the account id
})
server-side usage:
await auth.api.accountInfo({
query: { accountId: "accountId" },
headers: await headers() // headers containing the user's session token
});
Sometimes your application may need additional OAuth scopes after the user has already signed up (e.g., for accessing GitHub repositories or Google Drive). Users may not want to grant extensive permissions initially, preferring to start with minimal permissions and grant additional access as needed.
You can request additional scopes by using the linkSocial method with the same provider. This will trigger a new OAuth flow that requests the additional scopes while maintaining the existing account connection.
const requestAdditionalScopes = async () => {
await authClient.linkSocial({
provider: "google",
scopes: ["https://www.googleapis.com/auth/drive.file"],
});
};
Better Auth allows you to pass additional data through the OAuth flow without storing it in the database. This is useful for scenarios like tracking referral codes, analytics sources, or other temporary data that should be processed during authentication but not persisted.
When initiating OAuth sign-in or account linking, pass the additional data:
// Client-side: Sign in with additional data
await authClient.signIn.social({
provider: "google",
additionalData: {
referralCode: "ABC123",
source: "landing-page",
},
});
// Client-side: Link account with additional data
await authClient.linkSocial({
provider: "google",
additionalData: {
referralCode: "ABC123",
},
});
// Server-side: Sign in with additional data
await auth.api.signInSocial({
body: {
provider: "google",
additionalData: {
referralCode: "ABC123",
source: "admin-panel",
},
},
});
The additional data is available in your hooks during the OAuth callback through the getOAuthState.
Example using an after hook:
import { betterAuth } from "better-auth";
import { getOAuthState } from "better-auth/api";
export const auth = betterAuth({
// Other configurations...
hooks: {
after: [
{
matcher: () => true,
handler: async (ctx) => {
// Additional data is only available during OAuth callback
if (ctx.path === "/callback/:id") {
const additionalData = await getOAuthState<{
referralCode?: string;
source?: string;
}>();
if (additionalData) {
// IMPORTANT: Validate and sanitize the data before using it
// This data comes from the client and should not be trusted
// Example: Validate and process referral code
if (additionalData.referralCode) {
const isValidFormat = /^[A-Z0-9]{6}$/.test(additionalData.referralCode);
if (isValidFormat) {
// Verify the referral code exists in your database
const referral = await db.referrals.findByCode(additionalData.referralCode);
if (referral) {
// Safe to use the verified referral
await db.referrals.incrementUsage(referral.id);
}
}
}
// Track analytics (low-risk usage)
if (additionalData.source) {
await analytics.track("oauth_signin", {
source: additionalData.source,
userId: ctx.context.session?.user.id,
});
}
}
}
},
},
],
},
});
Example using a database hook:
// You can also access additional data in database hooks
databaseHooks: {
user: {
create: {
before: async (user, ctx) => {
if (ctx.path === "/callback/:id") {
const additionalData = await getOAuthState<{ referredFrom?: string }>();
if (additionalData?.referredFrom) {
return {
data: {
referredFrom: additionalData.referredFrom,
},
};
}
}
},
},
},
},
callbackURL - the callback URL for the OAuth flowcodeVerifier - the code verifier for the OAuth flowerrorURL - the error URL for the OAuth flownewUserURL - the new user URL for the OAuth flowlink - the link for the OAuth flow (email and user id)requestSignUp - whether to request sign up for the OAuth flowexpiresAt - the expiration time of the OAuth state[key: string]: any additional data you pass in the OAuth flow
</Callout>
Better Auth currently requires an email address on every user record. Most providers return one with the email scope, but several can legitimately omit it. When that happens the OAuth flow fails with error=email_not_found (or error=email_is_missing for the Generic OAuth plugin).
The table below summarises, for each affected provider, when email may be absent, which stable identifier you can use as a fallback in mapProfileToUser, and how much to trust the provider's email_verified signal.
| Provider | When email may be absent | Stable fallback ID | email_verified trust |
|---|---|---|---|
| Apple | Every sign-in after the first (Apple only emits email on the initial consent) | profile.sub (stable per Apple Team) | Reliable; relay addresses are also flagged verified |
| Discord | Phone-only accounts; email scope not granted | profile.id (snowflake) | Reliable (dedicated verified field) |
No valid email on file, even with the email permission granted | profile.id (app-scoped) | Unknown: Graph API exposes no per-email verification flag | |
| GitHub | User has set email to private; GitHub App lacks the "Email addresses" permission | profile.id (numeric) | Reliable |
No confirmed email on the member; email scope not granted | profile.sub (pairwise per app) | Reliable when present | |
| Microsoft Entra ID | Managed users without a mail attribute, unless email is configured as an optional claim | profile.oid plus profile.tid (or profile.sub) | Untrustworthy: Microsoft explicitly warns never to use for authorization |
| Roblox | The default Roblox profile flow does not return an email; Better Auth currently falls back to preferred_username | profile.sub (Roblox user ID) | Unknown for the default profile flow |
mapProfileToUserFall back to the provider's stable ID when the email field is null or absent:
import { betterAuth } from "better-auth";
export const auth = betterAuth({
socialProviders: {
discord: {
clientId: process.env.DISCORD_CLIENT_ID!,
clientSecret: process.env.DISCORD_CLIENT_SECRET!,
mapProfileToUser: (profile) => ({
email: profile.email ?? `${profile.id}@discord.placeholder.local`,
}),
},
apple: {
clientId: process.env.APPLE_CLIENT_ID!,
clientSecret: process.env.APPLE_CLIENT_SECRET!,
mapProfileToUser: (profile) => ({
email: profile.email ?? `${profile.sub}@apple.placeholder.local`,
}),
},
microsoft: {
clientId: process.env.MICROSOFT_CLIENT_ID!,
clientSecret: process.env.MICROSOFT_CLIENT_SECRET!,
mapProfileToUser: (profile) => ({
email: profile.email ?? `${profile.oid}@entra.placeholder.local`,
}),
},
},
});
email_verified and is_private_email are serialized as strings ("true" / "false"), not booleans.user:email scope is requested by default. Private emails still return null on /user; the primary verified address is available at /user/emails.email is tenant-mutable and never verified, use profile.oid (immutable, stable within the tenant) as the identity anchor; treat email as a profile attribute only. Microsoft's claims validation guidance explicitly warns never to use email, preferred_username, or unique_name for authorization decisions.First-class support for emailless users, using the stable (providerId, accountId) pair as the identity key (in line with OpenID Connect Core §5.7), is tracked in #9124.
The OAuth 2.0 Client ID issued by the provider.
For providers that verify ID tokens by audience (Google, Apple, Microsoft Entra, Facebook, Cognito), you can pass an array to accept tokens issued for any of the configured clients. The first entry is used when Better Auth drives the authorization code flow; all entries are accepted when verifying an ID token's aud claim. This enables cross-platform sign-in (Web, iOS, Android) with a single backend configuration, where each platform's native SDK issues tokens under its own Client ID.
import { betterAuth } from "better-auth";
export const auth = betterAuth({
// Other configurations...
socialProviders: {
google: {
clientId: [
process.env.GOOGLE_WEB_CLIENT_ID as string,
process.env.GOOGLE_IOS_CLIENT_ID as string,
process.env.GOOGLE_ANDROID_CLIENT_ID as string,
],
clientSecret: process.env.GOOGLE_CLIENT_SECRET as string,
},
},
});
All Client IDs must live in the same provider project so consent is shared. For providers that don't verify ID tokens by audience, only a single string is accepted.
The scope of the access request. For example, email or profile.
import { betterAuth } from "better-auth";
export const auth = betterAuth({
// Other configurations...
socialProviders: {
google: {
clientId: "YOUR_GOOGLE_CLIENT_ID",
clientSecret: "YOUR_GOOGLE_CLIENT_SECRET",
scope: ["email", "profile"],
},
},
});
Custom redirect URI for the provider. By default, it uses /api/auth/callback/${providerName}
import { betterAuth } from "better-auth";
export const auth = betterAuth({
// Other configurations...
socialProviders: {
google: {
clientId: "YOUR_GOOGLE_CLIENT_ID",
clientSecret: "YOUR_GOOGLE_CLIENT_SECRET",
redirectURI: "https://your-app.com/auth/callback",
},
},
});
Disables sign-up for new users.
Disables the use of the ID token for sign-in. By default, it's enabled for some providers like Google and Apple.
A custom function to verify the ID token.
A boolean value that determines whether to override the user information in the database when signing in. By default, it is set to false, meaning that the user information will not be overridden during sign-in. If you want to update the user information every time they sign in, set this to true.
A custom function to map the user profile returned from the provider to the user object in your database.
Useful, if you have additional fields in your user object you want to populate from the provider's profile. Or if you want to change how by default the user object is mapped.
import { betterAuth } from "better-auth";
export const auth = betterAuth({
// Other configurations...
socialProviders: {
google: {
clientId: "YOUR_GOOGLE_CLIENT_ID",
clientSecret: "YOUR_GOOGLE_CLIENT_SECRET",
mapProfileToUser: (profile) => {
return {
firstName: profile.given_name,
lastName: profile.family_name,
};
},
},
},
});
A custom function to refresh the token. This feature is only supported for built-in social providers (Google, Facebook, GitHub, etc.) and is not currently supported for custom OAuth providers configured through the Generic OAuth Plugin. For built-in providers, you can provide a custom function to refresh the token if needed.
import { betterAuth } from "better-auth";
export const auth = betterAuth({
// Other configurations...
socialProviders: {
google: {
clientId: "YOUR_GOOGLE_CLIENT_ID",
clientSecret: "YOUR_GOOGLE_CLIENT_SECRET",
refreshAccessToken: async (token) => {
return {
accessToken: "new-access-token",
refreshToken: "new-refresh-token",
};
},
},
},
});
The client key of your application. This is used by TikTok Social Provider instead of clientId.
import { betterAuth } from "better-auth";
export const auth = betterAuth({
// Other configurations...
socialProviders: {
tiktok: {
clientKey: "YOUR_TIKTOK_CLIENT_KEY",
clientSecret: "YOUR_TIKTOK_CLIENT_SECRET",
},
},
});
A custom function to get user info from the provider. This allows you to override the default user info retrieval process.
import { betterAuth } from "better-auth";
export const auth = betterAuth({
// Other configurations...
socialProviders: {
google: {
clientId: "YOUR_GOOGLE_CLIENT_ID",
clientSecret: "YOUR_GOOGLE_CLIENT_SECRET",
getUserInfo: async (token) => {
// Custom implementation to get user info
const response = await fetch("https://www.googleapis.com/oauth2/v2/userinfo", {
headers: {
Authorization: `Bearer ${token.accessToken}`,
},
});
const profile = await response.json();
return {
user: {
id: profile.id,
name: profile.name,
email: profile.email,
image: profile.picture,
emailVerified: profile.verified_email,
},
data: profile,
};
},
},
},
});
Disables implicit sign up for new users. When set to true for the provider, sign-in needs to be called with requestSignUp as true to create new users.
import { betterAuth } from "better-auth";
export const auth = betterAuth({
// Other configurations...
socialProviders: {
google: {
clientId: "YOUR_GOOGLE_CLIENT_ID",
clientSecret: "YOUR_GOOGLE_CLIENT_SECRET",
disableImplicitSignUp: true,
},
},
});
The prompt to use for the authorization code request. This controls the authentication flow behavior.
import { betterAuth } from "better-auth";
export const auth = betterAuth({
// Other configurations...
socialProviders: {
google: {
clientId: "YOUR_GOOGLE_CLIENT_ID",
clientSecret: "YOUR_GOOGLE_CLIENT_SECRET",
prompt: "select_account", // or "consent", "login", "none", "select_account+consent"
},
},
});
The response mode to use for the authorization code request. This determines how the authorization response is returned.
import { betterAuth } from "better-auth";
export const auth = betterAuth({
// Other configurations...
socialProviders: {
google: {
clientId: "YOUR_GOOGLE_CLIENT_ID",
clientSecret: "YOUR_GOOGLE_CLIENT_SECRET",
responseMode: "query", // or "form_post"
},
},
});
Removes the default scopes of the provider. By default, providers include certain scopes like email and profile. Set this to true to remove these default scopes and use only the scopes you specify.
import { betterAuth } from "better-auth";
export const auth = betterAuth({
// Other configurations...
socialProviders: {
google: {
clientId: "YOUR_GOOGLE_CLIENT_ID",
clientSecret: "YOUR_GOOGLE_CLIENT_SECRET",
disableDefaultScope: true,
scope: ["https://www.googleapis.com/auth/userinfo.email"], // Only this scope will be used
},
},
});
Each provider may have additional options, check the specific provider documentation for more details.