OAuth Provider
Add OAuth 2.1 consent, sign-up, account selection, and connected application management to your authentication flow.
The OAuth Provider plugin covers the user-facing screens Better Auth's OAuth 2.1 Provider redirects to. It also provides connected application security settings and OAuth client developer settings.
Client management
Enable personal client management to add an OAuth clients tab to user settings. The tab lists, creates, edits, deletes, and rotates secrets through Better Auth's signed-in client endpoints.
oauthProviderPlugin({
clientManagement: true
})The client secret appears only after creation or rotation. The user must copy it before closing the dialog.
Pass clientManager when personal clients also need server-only operations such as enable or disable. This manager uses { type: "user" } as its owner. The UI shows the enable or disable control when the manager implements setDisabled.
Organization clients need an application-owned OAuthClientManager. Every operation receives both the organization ID and slug. Authorize both values in your server endpoint. Do not infer the organization from active session state.
oauthProviderPlugin({
organizationClientManager: {
list: (owner, signal) => api.oauthClients.list(owner, signal),
create: (owner, input) => api.oauthClients.create(owner, input),
update: (owner, clientId, update) =>
api.oauthClients.update(owner, clientId, update),
delete: (owner, clientId) => api.oauthClients.delete(owner, clientId),
rotateSecret: (owner, clientId) =>
api.oauthClients.rotateSecret(owner, clientId),
setDisabled: (owner, clientId, disabled) =>
api.oauthClients.setDisabled(owner, clientId, disabled)
}
})Better Auth 1.7 exposes enable or disable through server admin APIs, so BAUI does not call it from the default browser adapter.
It contributes:
- An
<OAuthConsent />view at/auth/oauth-consent - An
<OAuthSignUp />view at/auth/oauth-sign-up, forprompt=create - An
<OAuthSelectAccount />view at/auth/select-account, forprompt=select_account - An
<AuthorizedApplications />card in security settings - Public OAuth client metadata loading
- Scope labels as a map, a list, or a resolver
- Headless continuation through
useOAuthContinue, for your own post-login screens
How the redirect screens fit together
Better Auth owns the authorization request. When it needs user input, it redirects to one of your pages.
The redirect includes the signed authorization query. Call oauth2.continue after the user provides the input:
| Prompt | Page | Continuation |
|---|---|---|
consent | consentPage | oauth2.consent({ accept }) |
create | signup.page | oauth2.continue({ created: true }) |
select_account | selectAccount.page | oauth2.continue({ selected: true }) |
| None | postLogin.page | oauth2.continue({ postLogin: true }) |
Keep the query string on every one of those pages. Do not strip it, rebuild it from redirect_uri, or navigate to the requested redirect yourself. oauthProviderClient() forwards the signed query to Better Auth, and Better Auth validates it and completes the redirect.
Setup
Configure the Better Auth server
Install the provider package:
bun add @better-auth/oauth-providerAdd the JWT and OAuth Provider plugins, and point each page option at the route that renders the matching view:
import { oauthProvider } from "@better-auth/oauth-provider"
import { betterAuth } from "better-auth"
import { jwt, multiSession } from "better-auth/plugins"
export const auth = betterAuth({
disabledPaths: ["/token"],
plugins: [
jwt(),
multiSession(),
oauthProvider({
loginPage: "/auth/sign-in",
consentPage: "/auth/oauth-consent",
signup: {
page: "/auth/oauth-sign-up"
},
selectAccount: {
page: "/auth/select-account",
shouldRedirect: async () => true
}
})
]
})signup and selectAccount both use loginPage by default. Set each page explicitly.
Each page uses a plugin route and does not replace /auth/sign-up. selectAccount.shouldRedirect controls when the application shows the chooser.
Return true to always show the chooser. Otherwise, use the session and scopes to make the decision.
Generate or migrate your Better Auth schema after enabling the server plugin:
bunx auth@latest migrateConfigure the browser client
Add oauthProviderClient() to the auth client. It preserves Better Auth's signed authorization query when the user responds:
import { oauthProviderClient } from "@better-auth/oauth-provider/client"
import { createAuthClient } from "better-auth/react"
import { multiSessionClient } from "better-auth/client/plugins"
export const authClient = createAuthClient({
plugins: [oauthProviderClient(), multiSessionClient()]
})multiSessionClient() is what makes the account chooser work: it lists the device sessions and switches the active one. Skip it if you do not use prompt=select_account.
Install the views
bunx shadcn@latest add @better-auth-ui/oauth-providerThis installs:
src/lib/auth/oauth-provider-plugin.tssrc/components/auth/oauth-provider/oauth-consent.tsxsrc/components/auth/oauth-provider/oauth-sign-up.tsxsrc/components/auth/oauth-provider/oauth-select-account.tsxsrc/components/auth/oauth-provider/authorized-applications.tsxand its row, empty-state, loading, and confirmation-dialog components
Register the UI plugin
import { AuthProvider } from "@/components/auth/auth-provider"
import { oauthProviderPlugin } from "@/lib/auth/oauth-provider-plugin"
<AuthProvider
authClient={authClient}
navigate={navigate}
plugins={[oauthProviderPlugin()]}
>
{children}
</AuthProvider>Allow the OAuth routes
Include the plugin paths in the route that renders <Auth />:
import { viewPaths } from "@better-auth-ui/core"
import { oauthProviderPlugin } from "@/lib/auth/oauth-provider-plugin"
const validAuthPathSegments = new Set([
...Object.values(viewPaths.auth),
...Object.values(oauthProviderPlugin().viewPaths.auth)
])Keep these paths aligned with the server's consentPage, signup.page, and selectAccount.page. If your route already resolves plugin paths without an allowlist, no route change is needed.
Scope metadata
scopeMetadata accepts three shapes. Every requested scope remains visible.
If a scope has no match, the plugin uses built-in metadata. The raw scope value is the final fallback.
Map
The original form. Good when the scope set is known up front:
oauthProviderPlugin({
scopeMetadata: {
calendar: {
label: "View your calendar",
description: "Read your calendar events and availability."
}
}
})List
Convenient when metadata comes out of a database or an API and arrives as an array:
oauthProviderPlugin({
scopeMetadata: [
{ scope: "calendar", label: "View your calendar" },
{ scope: "files", label: "View your files" }
]
})Resolver
For labels that depend on the requesting client or the rest of the scope set:
oauthProviderPlugin({
scopeMetadata: (scope, { clientId, requestedScopes }) => {
if (scope.startsWith("project:")) {
return { label: `Access ${scope.slice("project:".length)}` }
}
if (scope === "admin" && requestedScopes.includes("offline_access")) {
return {
label: "Administer your workspace",
description: `${clientId} can act on your behalf indefinitely.`
}
}
// Fall back to the built-in or raw label.
return undefined
}
})Returning undefined means "use the fallback", not "hide this scope".
Resolvers stay synchronous, so rendering is deterministic and behaves the same under SSR. If you need remote metadata, load it before render and pass a map or a list.
Sign-up continuation
<OAuthSignUp /> lives at its own route and wraps the same <SignUp /> component your app already uses: it does not replace the built-in sign-up view. Users who never go through OAuth never touch it.
When Better Auth redirects there with prompt=create, the ordinary sign-up implementation creates the account, and only after that succeeds does the view call:
authClient.oauth2.continue({ created: true })If the continuation request fails, the form shows a retry action. The account already exists, so do not submit the sign-up form again.
Reached without prompt=create, it renders plain sign-up and redirects the way sign-up normally does.
Limitations
Two flows deliberately do not continue on their own:
- Email verification. When
requireEmailVerificationis on, sign-up has no usable session yet, so the view sends the user to the verify-email screen instead. Resume after verification yourself withuseOAuthContinue. - Social sign-up. The provider redirect leaves and re-enters your app, so there is no "sign-up just succeeded" moment to hook into. Resume from your social callback route instead.
In both cases only call { created: true } if the account really was created during this flow. An already signed-in user is not a newly created one.
Account selection
<OAuthSelectAccount /> lists the device sessions from multiSession and lets the user pick one. Choosing the account that is already active continues directly. Choosing a different one calls multiSession.setActive() first, then continues: the switch always lands before Better Auth resumes.
Sessions are compared by session ID, never by user ID or list position.
The chooser has no sign-out or revoke actions on purpose. Session management belongs in security settings, not in the middle of an authorization request.
Post-login selection
There is no post-login view to install. An application can select a workspace, tenant, team, project, role, or another resource.
Build this selection interface in the application. When the selection is complete, use the headless continuation helper:
import { useAuth } from "@better-auth-ui/react"
import { useOAuthContinue } from "@better-auth-ui/react/plugins/oauth-provider"
type Workspace = { slug: string; name: string }
function SelectWorkspace({ workspaces }: { workspaces: Workspace[] }) {
const { authClient } = useAuth()
const oauthContinue = useOAuthContinue(authClient)
const select = async (slug: string) => {
// Persist the selection the way your app normally does — by slug or ID.
await setActiveWorkspaceSlug(slug)
await oauthContinue.mutateAsync({ postLogin: true })
}
return workspaces.map((workspace) => (
<button key={workspace.slug} onClick={() => select(workspace.slug)}>
{workspace.name}
</button>
))
}Point the server's postLogin.page at that route.
Do not use Better Auth active organizations here. Persist the selection with your own slug- or ID-based mechanism.
Connected applications
<AuthorizedApplications /> is a security card for authorized applications. It shows the client name, logo, granted scopes, and latest authorization date.
The card also provides a "Remove authorization" action.
Better Auth can store several consent records for one client, so records are grouped by client ID and rendered as a single application. Removing an application deletes every consent ID in that group. Each row loads its own client metadata, so one slow or missing application never blocks the rest of the card.
Turn the card off with:
oauthProviderPlugin({ showConnectedApplications: false })Removing an authorization deletes the stored consent record. The application needs the user's approval before it receives new access.
Existing access and refresh tokens stay valid until they expire. Better Auth does not provide complete token revocation through this endpoint.
Do not tell users that this action revokes existing tokens.
The card manages consent records only. It is not a session list or token list.
The card has no revoke-all control because Better Auth does not provide the required token operations.
Consent behavior
The consent view accepts or denies the complete requested scope set. It does not render per-scope controls. Omitting scope from the consent mutation tells Better Auth to accept the scopes from the original signed request.
The public client endpoint requires a signed-in session. Direct visits with missing request data, no session, or an unknown client render an invalid-request state.
Login reuses the existing signIn view and resumes automatically when Better Auth creates the session.
Components
import { AuthorizedApplications } from "@/components/auth/oauth-provider/authorized-applications"
import { OAuthConsent } from "@/components/auth/oauth-provider/oauth-consent"
import { OAuthSelectAccount } from "@/components/auth/oauth-provider/oauth-select-account"
import { OAuthSignUp } from "@/components/auth/oauth-provider/oauth-sign-up"
<OAuthConsent />
<OAuthSignUp />
<OAuthSelectAccount />
<AuthorizedApplications />Prop
Type
Prop
Type
Prop
Type
Plugin options
Prop
Type
Prop
Type
React APIs
usePublicOAuthClientloads application metadatauseOAuthConsentsubmits the user's decisionuseOAuthContinueresumes the request after a redirect screenuseListOAuthConsentslists authorized applicationsuseDeleteOAuthConsentremoves a stored consent
Each hook also exports its TanStack Query options factory.
Last updated on