Installation
1
Add the plugin to your auth config
auth.ts
import { betterAuth } from "better-auth"
import { genericOAuth } from "better-auth/plugins"
export const auth = betterAuth({
plugins: [
genericOAuth({
config: [
{
providerId: "my-provider",
clientId: process.env.PROVIDER_CLIENT_ID,
clientSecret: process.env.PROVIDER_CLIENT_SECRET,
discoveryUrl: "https://auth.example.com/.well-known/openid-configuration",
},
],
}),
],
})
2
Add the client plugin
auth-client.ts
import { createAuthClient } from "better-auth/client"
import { genericOAuthClient } from "better-auth/client/plugins"
export const authClient = createAuthClient({
plugins: [
genericOAuthClient()
]
})
Usage
Initiate sign-in
import { authClient } from "@/lib/auth-client"
await authClient.signIn.oauth2({
providerId: "my-provider",
callbackURL: "/dashboard",
errorCallbackURL: "/error",
newUserCallbackURL: "/welcome", // optional, for new users
scopes: ["openid", "email", "profile"], // override default scopes
})
Link an OAuth account
Add an OAuth provider to an existing user’s account:await authClient.oauth2.link({
providerId: "my-provider",
callbackURL: "/settings/accounts",
})
Callback URL
The plugin automatically mounts a callback handler at:{baseURL}/api/auth/oauth2/callback/{providerId}
Pre-configured providers
Better Auth ships with pre-configured helpers for popular providers:auth.ts
import { betterAuth } from "better-auth"
import {
genericOAuth,
auth0,
hubspot,
keycloak,
line,
microsoftEntraId,
okta,
slack,
patreon,
} from "better-auth/plugins"
export const auth = betterAuth({
plugins: [
genericOAuth({
config: [
auth0({
clientId: process.env.AUTH0_CLIENT_ID,
clientSecret: process.env.AUTH0_CLIENT_SECRET,
domain: process.env.AUTH0_DOMAIN, // e.g. dev-xxx.eu.auth0.com
}),
hubspot({
clientId: process.env.HUBSPOT_CLIENT_ID,
clientSecret: process.env.HUBSPOT_CLIENT_SECRET,
scopes: ["oauth", "contacts"],
}),
keycloak({
clientId: process.env.KEYCLOAK_CLIENT_ID,
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET,
issuer: process.env.KEYCLOAK_ISSUER, // https://my-domain/realms/MyRealm
}),
microsoftEntraId({
clientId: process.env.MS_APP_ID,
clientSecret: process.env.MS_CLIENT_SECRET,
tenantId: process.env.MS_TENANT_ID, // GUID, "common", "organizations", or "consumers"
}),
okta({
clientId: process.env.OKTA_CLIENT_ID,
clientSecret: process.env.OKTA_CLIENT_SECRET,
issuer: process.env.OKTA_ISSUER, // https://dev-xxxxx.okta.com/oauth2/default
}),
slack({
clientId: process.env.SLACK_CLIENT_ID,
clientSecret: process.env.SLACK_CLIENT_SECRET,
}),
patreon({
clientId: process.env.PATREON_CLIENT_ID,
clientSecret: process.env.PATREON_CLIENT_SECRET,
}),
// LINE supports multiple channels for different countries
line({
providerId: "line-jp",
clientId: process.env.LINE_JP_CLIENT_ID,
clientSecret: process.env.LINE_JP_CLIENT_SECRET,
}),
],
}),
],
})
Configuration options
Each provider configuration object supports:interface GenericOAuthConfig {
// Required
providerId: string // unique identifier for this provider
clientId: string // OAuth client ID
clientSecret: string // OAuth client secret
// Provider endpoints (auto-discovered if discoveryUrl is set)
discoveryUrl?: string // OIDC discovery document URL
authorizationUrl?: string // authorization endpoint
tokenUrl?: string // token endpoint
userInfoUrl?: string // userinfo endpoint
issuer?: string // expected issuer (for validation)
// Behavior
scopes?: string[] // scopes to request
redirectURI?: string // custom redirect URI
responseType?: string // defaults to "code"
responseMode?: string // "query" | "form_post"
prompt?: string // controls auth experience
pkce?: boolean // enable PKCE (default: false)
accessType?: string // use "offline" for refresh tokens
requireIssuerValidation?: boolean // strict issuer check
disableImplicitSignUp?: boolean // require explicit sign-up intent
disableSignUp?: boolean // block all new sign-ups
overrideUserInfo?: boolean // update user info on each sign-in
// Custom functions
getToken?: (params: { code: string; redirectURI: string }) => Promise<OAuth2Tokens>
getUserInfo?: (tokens: OAuth2Tokens) => Promise<User | null>
mapProfileToUser?: (profile: Record<string, any>) => Record<string, any>
authorizationUrlParams?: Record<string, string> | ((ctx) => Record<string, string>)
tokenUrlParams?: Record<string, string> | ((ctx) => Record<string, string>)
// Auth method for token requests: "basic" | "post" (default: "post")
authentication?: "basic" | "post"
}
Advanced usage
Custom token exchange
For providers with non-standard token endpoints:genericOAuth({
config: [
{
providerId: "custom-provider",
clientId: process.env.CUSTOM_CLIENT_ID,
clientSecret: process.env.CUSTOM_CLIENT_SECRET,
authorizationUrl: "https://provider.example.com/oauth/authorize",
scopes: ["profile", "email"],
getToken: async ({ code, redirectURI }) => {
// Some providers use GET instead of POST
const response = await fetch(
`https://provider.example.com/oauth/token?` +
`client_id=${process.env.CUSTOM_CLIENT_ID}&` +
`client_secret=${process.env.CUSTOM_CLIENT_SECRET}&` +
`code=${code}&redirect_uri=${redirectURI}&grant_type=authorization_code`,
{ method: "GET" }
)
const data = await response.json()
return {
accessToken: data.access_token,
refreshToken: data.refresh_token,
accessTokenExpiresAt: new Date(Date.now() + data.expires_in * 1000),
scopes: data.scope?.split(" ") ?? [],
raw: data, // preserve provider-specific fields
}
},
},
],
})
Custom user info fetching
genericOAuth({
config: [
{
providerId: "custom-provider",
// ...
getUserInfo: async (tokens) => {
const response = await fetch("https://provider.example.com/api/me", {
headers: { Authorization: `Bearer ${tokens.accessToken}` },
})
const data = await response.json()
return {
id: data.user_id,
email: data.email_address,
name: data.display_name,
image: data.avatar_url,
emailVerified: data.email_verified,
}
},
},
],
})
Profile field mapping
genericOAuth({
config: [
{
providerId: "custom-provider",
// ...
mapProfileToUser: async (profile) => ({
firstName: profile.given_name,
lastName: profile.family_name,
department: profile.custom_department,
}),
},
],
})
Accessing raw token data
Thetokens object includes a raw field with the original token response:
getUserInfo: async (tokens) => {
// Access provider-specific fields
const userId = tokens.raw?.provider_user_id as string
const customField = tokens.raw?.custom_provider_field as string
return { id: userId, /* ... */ }
}
Security: issuer validation
Better Auth validates the OAuth provider’s issuer to protect against mix-up attacks (RFC 9207).// Auto-discovery (issuer fetched from discovery document)
genericOAuth({
config: [{
providerId: "my-provider",
discoveryUrl: "https://auth.example.com/.well-known/openid-configuration",
clientId: "...",
clientSecret: "...",
}],
})
// Manual issuer with strict validation
genericOAuth({
config: [{
providerId: "secure-provider",
discoveryUrl: "https://auth.example.com/.well-known/openid-configuration",
clientId: "...",
clientSecret: "...",
requireIssuerValidation: true, // reject if iss parameter is missing
}],
})
| Scenario | requireIssuerValidation | Result |
|---|---|---|
iss matches expected | — | Success |
iss doesn’t match | — | issuer_mismatch error |
iss missing | false (default) | Success |
iss missing | true | issuer_missing error |
For maximum security with modern OIDC providers (Google, Auth0, Okta), enable
requireIssuerValidation: true.