Generic OAuth

Authenticate users with any OAuth provider

The Generic OAuth plugin lets you add any OAuth 2.1 or OpenID Connect (OIDC) provider to your application. Providers are registered as first-class social providers and use the standard signIn.social flow, with PKCE and issuer validation enabled by default.

When to Use This Plugin

Use the Generic OAuth plugin when:

  • Your provider is not one of the built-in social providers (Google, GitHub, Discord, etc.)
  • You need to connect to a corporate identity provider (Keycloak, Okta, Auth0, Microsoft Entra ID)
  • You want to support a provider with custom or non-standard OAuth endpoints

If your provider is already built-in, use socialProviders in your auth config instead; built-in providers support mobile id-token sign-in and have provider-specific optimizations.

Customization Options

Every aspect of the OAuth flow can be overridden:

NeedConfig field
Provider uses non-standard token exchange (GET, custom params)getToken
Provider returns a non-standard user profilegetUserInfo
You need to map profile fields to your user modelmapProfileToUser
Provider requires extra authorization parametersauthorizationUrlParams
Provider requires extra token parameterstokenUrlParams
Provider requires custom HTTP headersdiscoveryHeaders, authorizationHeaders
Provider does not support OIDC discoverySet authorizationUrl, tokenUrl, userInfoUrl explicitly
Provider configurations share one identity namespaceSet the same accountIssuer
Provider uses a non-standard immutable user identifierSet accountSubject
Provider rejects PKCESet pkce: false

Installation

Add the plugin to your auth config.

auth.ts
import { betterAuth } from "better-auth"
import { genericOAuth } from "better-auth/plugins"

export const auth = betterAuth({
    // ... other config options
    plugins: [
        genericOAuth({ 
            config: [ 
                { 
                    providerId: "provider-id", 
                    clientId: "test-client-id", 
                    clientSecret: "test-client-secret", 
                    discoveryUrl: "https://auth.example.com/.well-known/openid-configuration", 
                    // ... other config options
                }, 
                // Add more providers as needed
            ] 
        }) 
    ]
})

Usage

Generic OAuth providers are used through the standard social sign-in flow:

Sign In

await authClient.signIn.social({
    provider: "provider-id",
    callbackURL: "/dashboard",
})

You can also pass additionalData (a Record<string, any>) to round-trip client data through the flow; read it back on the callback with getOAuthState() and treat it as untrusted. See Passing Additional Data Through OAuth Flow.

Sign In with an ID Token

Providers configured with a discoveryUrl whose discovery document publishes a jwks_uri also accept a client-obtained id_token directly, skipping the redirect flow. The token is verified against the provider's JWKS before any claims are trusted:

await authClient.signIn.social({
    provider: "provider-id",
    idToken: {
        token: idTokenFromProvider,
    },
})

Providers configured with explicit endpoints instead of discoveryUrl do not support this path and return ID_TOKEN_NOT_SUPPORTED.

await authClient.linkSocial({
    provider: "provider-id",
    callbackURL: "/settings",
})

Handle OAuth Callback

The plugin uses the core OAuth callback route at /callback/:providerId. This means by default ${baseURL}/api/auth/callback/:providerId will be used as the callback URL. Make sure your OAuth provider is configured to use this URL.

Unlike built-in providers, the :providerId parameter is required and must match your configured provider ID.

Validate OAuth User Info

Use user.validateUserInfo to reject Generic OAuth users before Better Auth creates a user (create-user), links a new account (link-account), or signs an existing user back in (sign-in). On the sign-in action it receives the fresh provider email and profile, so a domain or org check can reject a user whose provider identity later moved out of bounds. Check source.oauth?.providerId to scope validation to a specific Generic OAuth provider, and use source.oauth?.profile for provider-specific profile fields.

auth.ts
import { betterAuth } from "better-auth";
import { genericOAuth } from "better-auth/plugins";

export const auth = betterAuth({
  user: {
    validateUserInfo: ({ user, source }) => {
      if (source.oauth?.providerId !== "company-oauth") {
        return;
      }

      if (!user.email?.endsWith("@example.com")) {
        return {
          error: "email_not_allowed",
          errorDescription: "Use your example.com email to sign in",
        };
      }
    },
  },
  plugins: [
    genericOAuth({
      config: [
        {
          providerId: "company-oauth",
          clientId: process.env.COMPANY_OAUTH_CLIENT_ID!,
          clientSecret: process.env.COMPANY_OAUTH_CLIENT_SECRET!,
          discoveryUrl: "https://auth.example.com/.well-known/openid-configuration",
        },
      ],
    }),
  ],
});

Return nothing to allow the OAuth flow. Return an object with error to reject it. Redirect-based OAuth flows send the rejection to the configured error URL, while programmatic flows return a 403 API error.

RP-Initiated Logout

For OIDC providers that expose an end_session_endpoint, authClient.signOut() will clear the Better Auth session and then redirect the browser to the provider logout endpoint. When you use discoveryUrl, the endpoint is read from the provider discovery document. You can also configure it manually with endSessionEndpoint.

await authClient.signOut()

Pass callbackURL to ask the provider to return the user to your app after logout. The URL must be registered with your provider as a post-logout redirect URI.

await authClient.signOut({
  callbackURL: "/login",
})

If multiple linked providers support RP-Initiated Logout, Better Auth redirects to the provider for the most recently updated account. A browser navigation can complete logout with only one provider at a time.

To handle the provider navigation yourself, set disableRedirect and use the returned url.

const { data } = await authClient.signOut({
  disableRedirect: true,
})

if (data?.url) {
  window.location.assign(data.url)
}

To keep sign-out local to Better Auth, disable provider logout for that config.

genericOAuth({
  config: [{
    providerId: "provider-id",
    discoveryUrl: "https://auth.example.com/.well-known/openid-configuration",
    clientId: "test-client-id",
    clientSecret: "test-client-secret",
    disableProviderLogout: true,
  }]
})

Pre-configured Provider Helpers

Better Auth provides pre-configured helper functions for popular OAuth providers. These helpers handle the provider-specific configuration, including discovery URLs and user info endpoints.

Supported Providers

  • Auth0 - auth0(options)
  • HubSpot - hubspot(options)
  • Keycloak - keycloak(options)
  • LINE - line(options)
  • Microsoft Entra ID (Azure AD) - microsoftEntraId(options)
  • Okta - okta(options)
  • Slack - slack(options)
  • Patreon - patreon(options)
  • Yandex - yandex(options)

Example: Using Pre-configured Providers

auth.ts
import { betterAuth } from 'better-auth';
import {
	// genericOAuth plugin
	genericOAuth,
	// providers
	auth0,
	gumroad,
	hubspot,
	keycloak,
	line,
	microsoftEntraId,
	okta,
	slack,
	patreon,
	yandex,
} 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,
				}),
				gumroad({
					clientId: process.env.GUMROAD_CLIENT_ID,
					clientSecret: process.env.GUMROAD_CLIENT_SECRET,
				}),
				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,
				}),
				// LINE supports multiple channels (countries) - use different providerIds
				line({
					providerId: 'line-jp',
					clientId: process.env.LINE_JP_CLIENT_ID,
					clientSecret: process.env.LINE_JP_CLIENT_SECRET,
				}),
				line({
					providerId: 'line-th',
					clientId: process.env.LINE_TH_CLIENT_ID,
					clientSecret: process.env.LINE_TH_CLIENT_SECRET,
				}),
				microsoftEntraId({
					clientId: process.env.MS_APP_ID,
					clientSecret: process.env.MS_CLIENT_SECRET,
					tenantId: process.env.MS_TENANT_ID,
				}),
				okta({
					clientId: process.env.OKTA_CLIENT_ID,
					clientSecret: process.env.OKTA_CLIENT_SECRET,
					issuer: process.env.OKTA_ISSUER,
				}),
				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,
				}),
				yandex({
					clientId: process.env.YANDEX_CLIENT_ID,
					clientSecret: process.env.YANDEX_CLIENT_SECRET,
				}),
			],
		}),
	],
});

Each provider helper accepts common OAuth options (extending BaseOAuthProviderOptions) plus provider-specific fields:

  • Auth0: Requires domain (e.g., dev-xxx.eu.auth0.com)
  • HubSpot: No additional required fields. Optional scopes (defaults to ["oauth"])
  • Keycloak: Requires issuer (e.g., https://my-domain/realms/MyRealm)
  • LINE: Optional providerId (defaults to "line"). LINE requires separate channels for different countries (Japan, Thailand, Taiwan, etc.), so you can call line() multiple times with different providerIds and credentials to support multiple countries
  • Microsoft Entra ID: Requires a concrete tenant GUID. Use the built-in Microsoft social provider for the multi-tenant "common", "organizations", or "consumers" authorities; those authorities require ID-token claim validation to determine the account's actual issuer
  • Okta: Requires issuer (e.g., https://dev-xxxxx.okta.com/oauth2/default)
  • Slack: No additional required fields
  • Patreon: No additional required fields
  • Yandex: No additional required fields

All providers support the same optional fields:

  • clientSecret?: string - OAuth client secret
  • tokenEndpointAuth?: TokenEndpointAuth - Client authentication configuration for token endpoint requests
  • scopes?: string[] - Array of OAuth scopes to request
  • redirectURI?: string - Custom redirect URI
  • pkce?: boolean - Enable PKCE (defaults to true)
  • disableImplicitSignUp?: boolean - Disable automatic sign-up for new users
  • disableSignUp?: boolean - Disable sign-up entirely
  • overrideUserInfo?: boolean - Override user info on sign in
  • endSessionEndpoint?: string - OIDC RP-Initiated Logout endpoint. Auto-discovered from end_session_endpoint when available
  • postLogoutRedirectURI?: string - Default URI to pass as post_logout_redirect_uri during provider logout
  • disableProviderLogout?: boolean - Disable automatic provider logout during authClient.signOut()

Configuration

When adding the plugin to your auth config, you can configure multiple OAuth providers. You can either use the pre-configured provider helpers (shown above) or create custom configurations manually.

Manual Configuration

Each provider configuration object supports the following options:

import type {
  OAuthAccountKeyContext,
  OAuth2Tokens,
  TokenEndpointAuth,
} from "better-auth/oauth2";
import type { GenericOAuthUserInfo } from "better-auth/plugins/generic-oauth";

interface GenericOAuthConfig {
  providerId: string;
  accountSubject?: (
    context: OAuthAccountKeyContext<GenericOAuthUserInfo>,
  ) => string | number | Promise<string | number>;
  accountIssuer?:
    | string
    | ((context: OAuthAccountKeyContext<GenericOAuthUserInfo>) => string | Promise<string>);
  discoveryUrl?: string;
  requireIdTokenVerification?: boolean;
  authorizationUrl?: string;
  tokenUrl?: string;
  userInfoUrl?: string;
  endSessionEndpoint?: string;
  postLogoutRedirectURI?: string;
  disableProviderLogout?: boolean;
  clientId: string;
  clientSecret?: string;
  tokenEndpointAuth?: TokenEndpointAuth;
  scopes?: string[];
  redirectURI?: string;
  responseType?: string;
  prompt?: string;
  pkce?: boolean;
  accessType?: string;
  accessTokenExpiresIn?: number;
  getUserInfo?: (tokens: OAuth2Tokens) => Promise<GenericOAuthUserInfo | null>;
}

Other Provider Configurations

providerId: A unique string to identify the OAuth provider configuration.

accountSubject: (Optional) Resolves the immutable user identifier assigned by the provider. OpenID Connect discovery providers use the verified sub field by default. Plain OAuth providers use id. Better Auth does not switch between those fields at runtime. Set this resolver when a provider uses another field, such as account_id. The resolver receives the OAuth tokens and raw provider profile, and can return a string or number.

mapProfileToUser cannot set the account subject. This keeps local profile mapping separate from account recognition.

accountIssuer: (Optional) The stable authority namespace paired with the provider account subject. A discovery provider uses its discovered issuer by default. Set this field when an explicit-endpoint provider has an issuer, when multiple provider configurations represent the same authority, or when a verified profile determines a tenant-specific issuer. Without discovery or accountIssuer, Better Auth uses local:oauth:<encoded providerId>, where the provider ID segment is percent-encoded.

The resolver receives verified provider data. Derive a dynamic issuer only from a claim or profile value established by the provider verification flow; never use a request parameter, email domain, or other caller-controlled value.

Provider configurations with the same accountIssuer and subject deduplicate one external identity. They share one account row and token set; aliases do not create independent grants or provider lifecycle records.

discoveryUrl: (Optional) URL to fetch the provider's OAuth 2.0/OIDC configuration. If provided, endpoints like authorizationUrl, tokenUrl, and userInfoUrl will be auto-discovered at server startup. When the discovery document publishes a jwks_uri, id_tokens returned by the provider are verified against it (signature, issuer, audience, and advertised signing algorithms) before their claims are used; a token that fails verification rejects the sign-in. Discovery providers also bind the id_token to the authorization request with a server-generated OIDC nonce and reject a callback whose id_token does not echo it. Provider initialization fails if discovery cannot establish a valid configuration, which prevents an account from switching between its discovered issuer and a local fallback.

requireIdTokenVerification: (Optional) Require discovery to provide a usable issuer and jwks_uri before registering the provider. Enable this when accountSubject or getUserInfo derives identity from ID-token claims. If discovery is unavailable or incomplete, initialization fails instead of falling back to unverified token decoding. The microsoftEntraId helper enables this automatically.

disableIdTokenNonceBinding: (Optional) Turn off OIDC nonce binding for a discovery provider's id_token. Binding is on by default and rejects an id_token that does not echo the server-generated nonce (OIDC Core 1.0 §3.1.3.7). Set this to true only for providers that do not return the nonce claim in the authorization-code flow; it removes id_token replay protection for the provider.

authorizationUrl: (Optional) The OAuth provider's authorization endpoint. Not required if using discoveryUrl.

tokenUrl: (Optional) The OAuth provider's token endpoint. Not required if using discoveryUrl.

userInfoUrl: (Optional) The endpoint to fetch user profile information. Not required if using discoveryUrl.

endSessionEndpoint: (Optional) The OIDC RP-Initiated Logout endpoint. Not required if using discoveryUrl and the provider returns end_session_endpoint.

postLogoutRedirectURI: (Optional) Default URI to send as post_logout_redirect_uri when authClient.signOut() redirects to the provider. This URI must be registered with the provider.

disableProviderLogout: (Optional) If true, authClient.signOut() only clears the Better Auth session and does not redirect to the provider logout endpoint.

clientId: The OAuth client ID issued by your provider.

clientSecret: The OAuth client secret issued by your provider.

tokenEndpointAuth: (Optional) Client authentication configuration for token endpoint requests. Use { method: "private_key_jwt", getClientAssertion } for RFC 7523 client assertions, { method: "client_secret_basic" } or { method: "client_secret_post" } for secret-based clients, and { method: "none" } for public clients. Secret-based methods require clientSecret; do not combine clientSecret with private_key_jwt or none. If omitted, Better Auth sends secret-based token requests when clientSecret is configured and public-client token requests when it is not.

scopes: (Optional) An array of scopes to request from the provider (e.g., ["openid", "email", "profile"]).

redirectURI: (Optional) The redirect URI to use for the OAuth flow. If not set, a default is constructed based on your app's base URL. Must include the :providerId placeholder (e.g., https://example.com/api/auth/callback/my-provider).

responseType: (Optional) The OAuth response type. Defaults to "code" for authorization code flow.

responseMode: (Optional) The response mode for the authorization code request, such as "query" or "form_post".

prompt: (Optional) Controls the authentication experience (e.g., force login, consent, etc.).

pkce: (Optional) Enables PKCE (Proof Key for Code Exchange), required by OAuth 2.1. Defaults to true. Disable only for providers that explicitly reject PKCE.

accessType: (Optional) The access type for the authorization request. Use "offline" to request a refresh token.

accessTokenExpiresIn: (Optional) Fallback access-token lifetime in seconds, used only when the provider's token response omits expires_in. Without a known expiry, getAccessToken cannot tell the token has expired and never refreshes it. Set this to the token's lifetime so the expiry is tracked and the token is refreshed when needed. Leave unset if the provider returns expires_in.

getToken: (Optional) A custom function to exchange authorization code for tokens. If provided, this function will be used instead of the default token exchange logic. This is useful for providers with non-standard token endpoints that use GET requests or custom parameters.

getUserInfo: (Optional) A custom function to fetch user info from the provider, given the OAuth tokens. If not provided, a default fetch is used.

mapProfileToUser: (Optional) A function to map the provider profile to mutable fields on your app's user. It cannot set provider identity; use accountSubject for a non-standard immutable identifier.

authorizationUrlParams: (Optional) Additional query parameters to add to the authorization URL. Reserved OAuth keys (state, client_id, redirect_uri, response_type, code_challenge, code_challenge_method, nonce, scope) are ignored so they cannot replace the values Better Auth manages for the flow; any other key overrides the default.

tokenUrlParams: (Optional) Additional query parameters to add to the token URL. Parameters already set by Better Auth are preserved. Configure token endpoint client authentication with clientId, clientSecret, and tokenEndpointAuth.

refreshTokenParams: (Optional) Additional body parameters merged into the token endpoint request when refreshing an access token. Accepts a plain object or a (sync or async) function that runs at refresh time, which makes it possible to inject dynamic values such as a scope, audience, resource, or tenant identifier without forcing a new authorization redirect. Examples: Zitadel's urn:zitadel:iam:org:id:{orgId} scope on workspace switch or Auth0 audience rotation. The function form receives request metadata (headers and cookies) from the triggering request, so request-scoped data can be read directly — callers MUST validate any header- or cookie-derived value against the authenticated user's entitlements before using it as a scope, audience, or tenant claim. grant_type and refresh_token cannot be overridden, and client_id is set by the configured token-endpoint authentication after the merge so it cannot be overridden here either.

genericOAuth({
  config: [
    {
      providerId: "zitadel",
      // ...
      refreshTokenParams: (ctx) => {
        const activeOrg = ctx?.headers?.get("x-active-org");
        return activeOrg
          ? { scope: `openid profile email urn:zitadel:iam:org:id:${activeOrg}` }
          : undefined;
      },
    },
  ],
});

disableImplicitSignUp: (Optional) If true, disables automatic sign-up for new users. Sign-in must be explicitly requested with sign-up intent.

disableSignUp: (Optional) If true, disables sign-up for new users entirely. Only existing users can sign in.

authentication: (Optional) Secret-based token request authentication. Can be 'basic' or 'post'. Defaults to 'post'. 'basic' requires clientSecret. For token endpoint methods beyond client secrets, configure tokenEndpointAuth.

For private_key_jwt, set tokenEndpointAuth.method to "private_key_jwt". You can provide your own getClientAssertion function, or use createPrivateKeyJwtClientAssertionGetter to sign an RFC 7523 JWT assertion from a private key:

import { createPrivateKeyJwtClientAssertionGetter } from "better-auth/oauth2";

genericOAuth({
    config: [
        {
            providerId: "my-idp",
            clientId: "your-client-id",
            tokenUrl: "https://idp.example.com/oauth/token",
            authorizationUrl: "https://idp.example.com/oauth/authorize",
            tokenEndpointAuth: {
                method: "private_key_jwt",
                getClientAssertion: createPrivateKeyJwtClientAssertionGetter({
                    privateKeyJwk: { /* your JWK */ },
                    kid: "my-key-1",
                    algorithm: "RS256",
                }),
            },
            pkce: true,
        },
    ],
}),

You can also return an assertion from another provider:

genericOAuth({
    config: [
        {
            providerId: "my-idp",
            clientId: process.env.IDP_CLIENT_ID!,
            discoveryUrl: "https://idp.example.com/.well-known/openid-configuration",
            tokenEndpointAuth: {
                method: "private_key_jwt",
                getClientAssertion: async ({ clientId, tokenEndpoint, grantType }) => {
                    return getWorkloadIdentityToken({
                        clientId,
                        audience: tokenEndpoint,
                        grantType,
                    });
                },
            },
            pkce: true,
        },
    ],
});

discoveryHeaders: (Optional) Custom headers to include in the discovery request. Useful for providers that require special headers.

authorizationHeaders: (Optional) Custom headers to include in the authorization request. Useful for providers that require special headers.

overrideUserInfo: (Optional) If true, the user's info in your database will be updated with the provider's info every time they sign in. Defaults to false.

Advanced Usage

Custom Token Exchange

For providers with non-standard token endpoints that use GET requests or custom parameters, you can provide a custom getToken function:

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"],
      // Custom token exchange for non-standard endpoints
      getToken: async ({ code, redirectURI }) => {
        // Example: GET request 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(" ") ?? [],
          // Preserve provider-specific fields in raw
          raw: data,
        };
      },
      getUserInfo: async (tokens) => {
        // Access provider-specific fields from raw token data
        const userId = tokens.raw?.user_id as string;

        const response = await fetch(
          `https://provider.example.com/api/user?` +
          `access_token=${tokens.accessToken}`
        );

        const data = await response.json();

        return {
          id: userId,
          name: data.display_name,
          email: data.email,
          image: data.avatar_url,
          emailVerified: data.email_verified,
        };
      },
    },
  ],
});

Custom User Info Fetching

You can provide a custom getUserInfo function to handle specific provider requirements:

genericOAuth({
  config: [
    {
      providerId: "custom-provider",
      // ... other config options
      getUserInfo: async (tokens) => {
        // Custom logic to fetch and return user info
        const userInfo = await fetchUserInfoFromCustomProvider(tokens);
        return {
          id: userInfo.sub,
          email: userInfo.email,
          name: userInfo.name,
          // ... map other fields as needed
        };
      }
    }
  ]
})

Map User Info Fields

If the user info returned by the provider does not match the expected format, or you need to map additional fields, you can use the mapProfileToUser:

genericOAuth({
  config: [
    {
      providerId: "custom-provider",
      // ... other config options
      mapProfileToUser: async (profile) => {
        return {
          firstName: profile.given_name,
          // ... map other fields as needed
        };
      }
    }
  ]
})

Accessing Raw Token Data

The tokens parameter includes a raw field that preserves the original token response from the provider. This is useful for accessing provider-specific fields:

getUserInfo: async (tokens) => {
  // Access provider-specific fields
  const customField = tokens.raw?.custom_provider_field as string;
  const userId = tokens.raw?.provider_user_id as string;

  // Use in your logic
  return {
    id: userId,
    // ...
  };
}

IDP-Initiated Flows

Some OAuth providers (e.g., Clever) let the identity provider redirect users to your callback URL without first having the user click a "sign in" button in your app. These callbacks arrive with a code parameter but no state, which would normally be rejected because the missing state breaks the standard CSRF check.

Set allowIdpInitiated: true on the provider to accept these flows safely:

auth.ts
import { betterAuth } from "better-auth";
import { genericOAuth } from "better-auth/plugins";

export const auth = betterAuth({
    plugins: [
        genericOAuth({
            config: [
                {
                    providerId: "clever",
                    discoveryUrl: "https://clever.com/.well-known/openid-configuration",
                    clientId: process.env.CLEVER_CLIENT_ID,
                    clientSecret: process.env.CLEVER_CLIENT_SECRET,
                    allowIdpInitiated: true,
                },
            ],
        }),
    ],
});

When a stateless callback hits /callback/:providerId, Better Auth discards the provider-issued code and restarts the flow server-side: a fresh state and PKCE verifier are generated, and the user is redirected to the provider's authorize endpoint. The provider recognizes the user's live session and completes the flow automatically. CSRF protection and PKCE are preserved throughout.

Leave allowIdpInitiated off (the default) for any provider that always initiates the flow from your side — stateless callbacks to those providers indicate malformed or malicious requests and should be rejected.

Set the global baseURL option when enabling allowIdpInitiated. IDP-initiated callbacks carry no initiating request body, so the bounce uses baseURL as the post-login destination. If baseURL is unset, the bounce fails with CALLBACK_URL_REQUIRED.

Error Handling

The plugin includes built-in error handling for common OAuth issues. Errors are typically redirected to your application's error page with an appropriate error message in the URL parameters. If the callback URL is not provided, the user will be redirected to Better Auth's default error page.