The Generic OAuth plugin provides a flexible way to integrate authentication with any OAuth 2.0 or OpenID Connect (OIDC) provider. Use it to add social login, enterprise IdP authentication, or any custom OAuth flow.
Installation
Add the plugin to your auth config
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",
},
],
}),
],
})
Add the client plugin
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}
Configure your OAuth provider to use this URL as the redirect URI.
Better Auth ships with pre-configured helpers for popular providers:
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
The tokens 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.