BETTER-AUTH. UI
Plugins

OAuth Provider

Add OAuth 2.1 consent, sign-up, account selection, and connected application management to Solid and Zaidan.

The OAuth Provider plugin provides the pages used by the Better Auth OAuth 2.1 Provider. It also provides a security card for authorized applications.

It contributes:

  • A copied <OAuthConsent /> view at /auth/oauth-consent
  • A copied <OAuthSignUp /> view at /auth/oauth-sign-up, for prompt=create
  • A copied <OAuthSelectAccount /> view at /auth/select-account, for prompt=select_account
  • A copied <AuthorizedApplications /> card in security settings
  • Public OAuth client metadata loading
  • Scope labels as a map, a list, or a resolver
  • Headless continuation through oauthContinueOptions, 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:

PromptPageContinuation
consentconsentPageoauth2.consent({ accept })
createsignup.pageoauth2.continue({ created: true })
select_accountselectAccount.pageoauth2.continue({ selected: true })
NonepostLogin.pageoauth2.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-provider

Add the JWT and OAuth Provider plugins, and point each page option at the route that renders the matching view:

src/lib/auth.ts
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 migrate

Configure the Solid client

Add oauthProviderClient() to the auth client. It preserves Better Auth's signed authorization query when the user responds:

src/lib/auth-client.ts
import { oauthProviderClient } from "@better-auth/oauth-provider/client"
import { createAuthClient } from "@better-auth-ui/solid"
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 Solid and Zaidan views

bunx shadcn@latest add https://better-auth-ui.com/r/solid/oauth-provider.json

This installs:

  • src/lib/auth/oauth-provider-plugin.ts
  • src/components/auth/oauth-provider/oauth-consent.tsx
  • src/components/auth/oauth-provider/oauth-sign-up.tsx
  • src/components/auth/oauth-provider/oauth-select-account.tsx
  • src/components/auth/oauth-provider/authorized-applications.tsx and its row, empty-state, loading, and confirmation-dialog components
  • src/components/auth/user/user-avatar.tsx

Register the UI plugin

src/components/providers.tsx
import { AuthProvider } from "@/components/auth/auth-provider"
import { oauthProviderPlugin } from "@/lib/auth/oauth-provider-plugin"

<AuthProvider
  authClient={authClient}
  plugins={[oauthProviderPlugin()]}
>
  {children}
</AuthProvider>

Allow the OAuth routes

Include the plugin paths in the route that renders <Auth />:

src/routes/auth/$path.tsx
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.

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:

src/components/providers.tsx
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:

src/components/providers.tsx
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:

src/components/providers.tsx
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 requireEmailVerification is on, sign-up has no usable session yet, so the view sends the user to the verify-email screen instead. Resume after verification yourself with oauthContinueOptions.
  • 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 using the Zaidan Item primitives. 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:

src/routes/auth/select-workspace.tsx
import { oauthContinueOptions } from "@better-auth-ui/core/plugins/oauth-provider"
import { useAuth } from "@better-auth-ui/solid"
import { createMutation } from "@tanstack/solid-query"
import { For } from "solid-js"

type Workspace = { slug: string; name: string }

function SelectWorkspace(props: { workspaces: Workspace[] }) {
  const auth = useAuth()
  const oauthContinue = createMutation(() =>
    oauthContinueOptions(auth.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 (
    <For each={props.workspaces}>
      {(workspace) => (
        <button onClick={() => select(workspace.slug)} type="button">
          {workspace.name}
        </button>
      )}
    </For>
  )
}

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.

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

Prop

Type

Plugin options

Prop

Type

Prop

Type

Solid APIs

Last updated on

On this page