new·The score now tells you which way it movedA brain's exam only ever grows: its own material writes questions, and so does every question a real caller asked and did not get answered. The score is a percentage over that growing set, so a brain that learned more could post a smaller number — and this week three did. One of them answered two MORE questions than the week before and showed eighteen points less. Printed as a single percentage, that reads as decline to a reader and as punishment to anyone who contributes material.all news →
mozg.beta
Sign in

Better Auth · Plugins · all subjects

oauth-provider/configuration

53 notes, read out of this brain and free to use. Each one was extracted from a source and is re-checked against its exam.

Dynamic Client Registration - Enable Configuration

To enable client registration, set allowDynamicClientRegistration: true in BetterAuth config. To enable unauthenticated client registration allowing dynamically registered public clients, additionally set allowUnauthenticatedClientRegistration: true. Note: allowUnauthenticatedClientRegistration will be deprecated when MCP protocol standardizes unauthenticated dynamic client registration.

OpenID Configuration - Well-Known Endpoint

Provides OpenID Connect discovery metadata at {issuer}/.well-known/openid-configuration. Requires openid scope. If not using catch-all auth route forwarding this URL, add route: import { oauthProviderOpenIdConfigMetadata } from "@better-auth/oauth-provider"; export const GET = oauthProviderOpenIdConfigMetadata(auth);

OAuth Authorization Server Metadata - Well-Known Endpoint

Provides RFC 8414-compliant metadata for authorization server at both {issuer}/.well-known/oauth-authorization-server and /.well-known/oauth-authorization-server/[issuer-path]. If route not forwarding, add: import { oauthProviderAuthServerMetadata } from "@better-auth/oauth-provider"; export const GET = oauthProviderAuthServerMetadata(auth);

Scopes vs Permissions - Conceptual Difference

Scopes define what client application requests on behalf of user (coarse-grained labels in access token). Permissions define fine-grained actions user/service can perform on resources (enforced at resource server). Can combine approaches depending on system complexity.

OAuth Provider - Login Screen Configuration

Configure login page by providing loginPage option: oauthProvider({ loginPage: "/sign-in" }). When user redirected to OIDC provider and not logged in, redirected to login page. No additional handling needed; plugin handles continuing authorization flow when session created.

OAuth Provider - Consent Screen Configuration

Configure consent page: oauthProvider({ consentPage: "/consent" }). Plugin redirects user to specified path with client_id and scope query parameters. Display custom consent screen, then call oauth2.consent to complete authorization. Trusted clients with skipConsent: true bypass consent entirely.

OAuth Provider - Consent Screen Implementation

After user consents on consent screen, call: const res = await authClient.oauth2.consent({ accept: true, scope: "openid profile email" });

OAuth Provider - Sign Up Account Screen Configuration

Configure sign up using prompt: create parameter: oauthProvider({ signUp: { page: "/sign-up" } }). Use shouldRedirect function to stop sign-in process for completing registration: oauthProvider({ signUp: { page: "/sign-up", shouldRedirect: async ({ headers }) => { const isUserRegistered = await userRegistered(headers); return isUserRegistered ? false : "/setup"; } } })

OAuth Provider - Select Account Screen Configuration

Configure account selection: oauthProvider({ selectAccount: { page: "/select-account", shouldRedirect: async ({ headers }) => { const allSessions = await auth.api.listDeviceSessions({ headers }); return allSessions?.length >= 1; } } }). Plugin redirects to selectAccount.page. Page should prompt for account selection then call oauth2Continue({ selected: true }).

OAuth Provider - Select Account Screen Implementation

After account selection: await authClient.multiSession.setActive({ sessionToken }); await client.oauth2.oauth2Continue({ selected: true });

OAuth Provider - Post Login Screen Configuration

For organization-specific scopes, configure post login: oauthProvider({ scopes: ["openid", "profile", "email", "read:organization"], postLogin: { page: "/select-organization", shouldRedirect: async ({ session, scopes, headers }) => { const userOnlyScopes = ["openid", "profile", "email", "offline_access"]; if (scopes.every((sc) => userOnlyScopes.includes(sc))) return false; const organizations = await auth.api.listOrganizations({ headers }); return organizations.length > 1 || !(organizations.length === 1 && organizations.at(0)?.id === session.activeOrganizationId); }, consentReferenceId: ({ session, scopes }) => { if (scopes.includes("read:organization")) { const activeOrganizationId = (session?.activeOrganizationId ?? undefined) as string | undefined; if (!activeOrganizationId) { throw new APIError("BAD_REQUEST", { error: "set_organization", error_description: "must set organization for these scopes" }); } return activeOrganizationId; } else { return undefined; } } } }).

OAuth Provider - Post Login Screen Implementation

After organization selection: await authClient.organization.setActive({ organizationId }); await client.oauth2.oauth2Continue({ postLogin: true });

OAuth Provider - Cached Trusted Clients Configuration

For first-party applications and internal services, cache trusted clients for performance. Values cached in memory and prevent changes through CRUD endpoints: oauthProvider({ cachedTrustedClients: new Set(["internal-dashboard", "mobile-app"]) })

OAuth Provider - Valid Audiences Configuration

List valid audiences (resources) for oauth server: oauthProvider({ validAudiences: ["https://api.example.com", "https://api.example.com/mcp"] }). If not specified, default audience is baseUrl. Recommended to specify audience other than baseUrl such as API.

OAuth Provider - Scopes Configuration

Configure supported scopes: oauthProvider({ scopes: ["openid", "profile", "offline_access", "read:post", "write:post"] }). By default supports: openid (returns user ID sub claim), profile (name, picture, given_name, family_name), email (email and email_verified), offline_access (returns refresh token). All supported scopes must be in array. openid required for OIDC server.

OAuth Provider - Claims Configuration

Internally supports claims: ["sub", "iss", "aud", "exp", "iat", "sid", "scope", "azp"]. Custom claims in customIdTokenClaims and customUserInfoClaims should be namespaced to avoid conflicts. These functions can throw errors such as when user no longer member of organization or lacks permissions.

OAuth Provider - Custom Claims Configuration

Configure custom claims: oauthProvider({ customIdTokenClaims: ({ user, scopes, metadata }) => { return { locale: "en-GB" }; }, customAccessTokenClaims: ({ user, scopes, referenceId, resource, metadata }) => { return { "https://example.com/org": referenceId, "https://example.com/roles": ["editor"] }; }, customUserInfoClaims: ({ user, scopes, jwt }) => { return { locale: "en-GB" }; } })

OAuth Provider - Custom Token Response Fields

Add fields to token endpoint JSON response (alongside access_token, token_type): oauthProvider({ customTokenResponseFields: ({ grantType, user, scopes, metadata, verificationValue }) => { if (grantType === "authorization_code" && verificationValue?.referenceId) { return { tenant_id: verificationValue.referenceId }; } return {}; } }). Callback receives grant type, user (undefined for client_credentials), scopes, parsed client metadata, verification value (only for authorization_code). Called before tokens created, so errors prevent partial state.

OAuth Provider - Token Expirations Configuration

Configure expiration for token types and grant types: oauthProvider({ accessTokenExpiresIn: "1h" (default), m2mAccessTokenExpiresIn: "1h" (default), idTokenExpiresIn: "10h" (default), refreshTokenExpiresIn: "30d" (default), codeExpiresIn: "10m" (default) })

OAuth Provider - Scope Expirations Configuration

Set lower expirations for higher-privilege scopes: oauthProvider({ scopeExpirations: { "write:payments": "5m", "read:payments": "30m" } }). Values must be lower than defaults. Earliest expiration takes precedence.

OAuth Provider - Dynamic Client Registration Expiration

Set expiration for dynamically registered confidential clients: oauthProvider({ allowDynamicClientRegistration: true, clientRegistrationClientSecretExpiration: "30d" }). By default, dynamically registered confidential clients do not expire.

OAuth Provider - Dynamic Client Registration Scopes

Set default scopes for newly registered clients: oauthProvider({ scopes: ["reader", "editor"], clientRegistrationDefaultScopes: ["reader"] }). Set allowed scopes in addition to defaults: oauthProvider({ clientRegistrationAllowedScopes: ["editor"] }). All scopes must be defined in scopes array.

OAuth Provider - PKCE Configuration Overview

PKCE (Proof Key for Code Exchange) is security mechanism preventing authorization code interception. Plugin follows OAuth 2.1 spec, requiring PKCE by default. PKCE always required for public clients and any authorization request with offline_access scope. Individual confidential clients can opt-out via require_pkce: false for legacy compatibility.

OAuth Provider - Per-Client PKCE Opt-Out

Register confidential client without PKCE: const response = await auth.api.createOAuthClient({ headers, body: { client_name: 'Legacy Backend Service', redirect_uris: ['https://app.example.com/callback'], token_endpoint_auth_method: 'client_secret_post', grant_types: ['authorization_code'], require_pkce: false } });

OAuth Provider - PKCE require_pkce Field Behavior

The require_pkce field defaults to true (PKCE required), only applies to confidential clients, is ignored for public clients (PKCE always required), and is ignored for offline_access scope (PKCE always required). Use require_pkce: false only for legacy compatibility; recommendation is to keep PKCE enabled (default).

OAuth Provider - Organizations Configuration

OAuth Clients tied to either user or reference_id at registration (immutable). If using organization plugin, ensure activeOrganizationId set on active session when creating clients: oauthProvider({ clientReference: ({ session }) => { return (session?.activeOrganizationId as string | undefined) ?? undefined; } })

OAuth Provider - Client CRUD Privileges Configuration

Determine if logged-in user can perform client CRUD actions: oauthProvider({ clientPrivileges: async ({ action, headers, user, session }) => { if (!session?.activeOrganizationId) return false; const { data: member } = await auth.api.getActiveMember({ headers }); return member.role === 'owner'; } }}). By default, CRUD allowed for users with matching userId or clientReference.

OAuth Provider - Storage Configuration

By default all secrets hashed on database (protects client_secret in case of leak). storeClientSecret: storage method for client_secrets (hashed by default, encrypted only when disableJwtPlugin: true). storeTokens: storage method for token values (session refresh tokens and opaque access tokens).

OAuth Provider - Rate Limiting Overview

Built-in rate limiting for all OAuth endpoints. Rate limiting is per-IP per-endpoint. Each client IP has its own counter per endpoint. Resets after window expires. Only applies when Better Auth's global rate limiting enabled (default in production only).

OAuth Provider - Default Rate Limits

Default rate limits: /oauth2/token 60s window 20 max, /oauth2/authorize 60s window 30 max, /oauth2/introspect 60s window 100 max, /oauth2/revoke 60s window 30 max, /oauth2/register 60s window 5 max, /oauth2/userinfo 60s window 60 max.

OAuth Provider - Customize Rate Limits

Custom rate limits: oauthProvider({ rateLimit: { token: { window: 60, max: 20 }, authorize: { window: 60, max: 30 }, introspect: { window: 60, max: 100 }, revoke: { window: 60, max: 30 }, register: { window: 60, max: 5 }, userinfo: { window: 60, max: 60 } } }}). Set endpoint to false to use global rate limits instead.

OAuth Provider - Refresh Token Customization

Format refresh tokens in different string format: oauthProvider({ formatRefreshToken: { encrypt: (token, sessionId) => { const res = sessionId ? `1.${token}.${sessionId}` : token; return res; }, decrypt: (token) => { const tokenSplit = token.split('.'); if (tokenSplit.length === 3 && tokenSplit.at(0) === '1') { return { token: tokenSplit.at(1), sessionId: tokenSplit.at(2) }; } return { token }; } } }})

OAuth Provider - Advertised Metadata Scopes

Customize publicized scopes on metadata endpoint: oauthProvider({ scopes: ["openid", "profile", "email", "offline_access", "read:post"], advertisedMetadata: { scopes_supported: ["openid", "profile", "read:post"] } }). All scopes in advertisedMetadata MUST be listed in scopes.

OAuth Provider - Advertised Metadata Claims

Customize advertised claims (for OIDC with openid scope): oauthProvider({ advertisedMetadata: { claims_supported: ["https://example.com/roles"] } }}). Claims in addition to internally supported claims.

OAuth Provider - Disable JWT Plugin

Disable JWT requirement: oauthProvider({ disableJwtPlugin: true }). Access tokens always opaque, id_tokens signed in HS256 using client_secret. Still OIDC compliant: /userinfo works, signed id_token provided. Valid resource always provides opaque access token instead of JWT. id_token not returned for public clients but access_token can use /oauth2/userinfo. id_token for confidential client signed by client_secret.

OAuth Provider - Pairwise Subject Identifiers Overview

By default, sub (subject) claim uses user's internal ID (same across all clients) - public subject type per OIDC Core Section 8. Pairwise subject identifiers give each client unique, unlinkable sub for same user, preventing correlation across services.

OAuth Provider - Pairwise Secret Configuration

Enable pairwise subject identifiers: oauthProvider({ pairwiseSecret: "your-256-bit-secret" }}). When configured, server advertises both "public" and "pairwise" in discovery endpoint's subject_types_supported. Clients opt-in by setting subject_type: "pairwise" at registration.

OAuth Provider - Pairwise Client Configuration

Register client with pairwise subject type: const response = await auth.api.createOAuthClient({ headers, body: { client_name: 'Privacy-Sensitive App', redirect_uris: ['https://app.example.com/callback'], token_endpoint_auth_method: 'client_secret_post', subject_type: 'pairwise' } });

OAuth Provider - Pairwise Identifiers How It Works

Pairwise identifiers computed using HMAC-SHA256 over sector identifier (host of client's first redirect URI) and user ID, keyed with pairwiseSecret. Two clients with different redirect URI hosts receive different sub values for same user. Two clients sharing same redirect URI host receive same pairwise sub. Same client always receives same sub for same user (deterministic). Appears in id_token, /oauth2/userinfo, token introspection.

OAuth Provider - Pairwise Identifiers Limitations

Limitations: sector_identifier_uri not yet supported, all redirect_uris for pairwise client must share same host, clients with redirect URIs on different hosts rejected at registration. pairwiseSecret must be at least 32 characters. Rotating pairwiseSecret changes all pairwise sub values, breaking existing RP sessions - treat as permanent.

OAuth Provider - Pairwise JWT Access Token Note

JWT access tokens always use real user ID as sub (not pairwise), since resource servers may need to look up users directly.

OAuth Provider - MCP Compatibility

Make APIs MCP-compatible by adding resource server directing users to OAuth 2.1 authorization server. If using openid with confidential MCP clients, cannot disable JWT plugin since id_token verification may not be supported via client_secret.

MCP Installation - Resource Server Client

Add resource server client: import { auth } from "@/lib/auth"; import { createAuthClient } from "better-auth/client"; import { oauthProviderResourceClient } from "@better-auth/oauth-provider/resource-client"; export const serverClient = createAuthClient({ plugins: [oauthProviderResourceClient(auth)] });

MCP Installation - Protected Resource Metadata

Add OAuth Protected Resource metadata to API: import { serverClient } from "@/lib/server-client"; export const GET = async () => { const metadata = await serverClient.getProtectedResourceMetadata({ resource: "https://api.example.com", authorization_servers: ["https://auth.example.com"] }); return new Response(JSON.stringify(metadata), { headers: { "Content-Type": "application/json", "Cache-Control": "public, max-age=15, stale-while-revalidate=15, stale-if-error=86400" } }); };

MCP Installation - Confidential Client for API Server

If using allowUnauthenticatedClientRegistration, API Server must be confidential client: await auth.api.createOAuthClient({ headers, body: { redirect_uris: [redirectUri] } }); Use values in verify options remoteVerify.clientId and remoteVerify.clientSecret. remoteVerify.introspectUrl like ${BASE_URL}/${AUTH_PATH}/oauth2/introspect.

MCP Installation - mcpHandler Helper

Use mcpHandler helper for MCP errors: import { createMcpHandler } from "mcp-handler"; import { mcpHandler } from "@better-auth/oauth-provider"; const handler = mcpHandler({ jwksUrl: "https://auth.example.com/api/auth/jwks", verifyOptions: { issuer: "https://auth.example.com", audience: "https://api.example.com" } }, (req, jwt) => { ... }); export { handler as GET, handler as POST, handler as DELETE };

Prefix configuration for token security

The prefix option allows adding prefixes to opaque access tokens, refresh tokens, and client secrets. This is useful for secret scanners like GitHub Secret Scanners, GitGuardian, and Trufflehog. Configuration options include: opaqueAccessToken (string or undefined), refreshToken (string or undefined), and clientSecret (string or undefined). Prefixes should be added prior to first production deployment and considered immutable thereafter. If previously deployed without prefixes, use generateOpaqueAccessToken, generateRefreshToken, or generateClientSecret functions instead.

OAuth provider database performance optimization

To improve lookup performance, database adapters may map the field client_id on the oauthClient table to id. The id field should support strings formatted like UUIDs and URLs.

Migration from OIDC Provider plugin: configuration changes

When migrating from OIDC Provider plugin to OAuth Provider plugin, configuration changes include: idTokenExpiresIn now defaults to 10 hours (previously 1 hour), refreshTokenExpiresIn now defaults to 30 days (previously 7 days), advertisedMetadata no longer supports changing fields, clientRegistrationDefaultScopes is now in array format instead of space-separated string, consentPage is now required, getConsentHTML is removed in favor of consentPage, requirePKCE global option is removed (PKCE now required by default per OAuth 2.1 with per-client opt-out), allowPlainCodeChallengeMethod is removed, customUserInfoClaims passes jwt payload instead of client, storeClientSecret defaults to hashed or encrypted (previously plain), JWT plugin is now enabled by default (disable with disableJwtPlugin: true), and code_challenge_method 'S256' must be uppercase.

Migration from OIDC Provider plugin: oauthClient table changes

When migrating from OIDC Provider plugin, the oauthClient table (previously oauthApplication) requires: all stored clientSecret values must be hashed to SHA-256 representation in base64Url format if storeClientSecret was unset or plain; type field is no longer required, replaced by public (boolean) field with migration rules: type 'public' becomes type undefined and public true with clientSecret undefined, type 'native' or 'user-agent-based' becomes public true and clientSecret undefined, clientSecret undefined becomes public true; redirectURLs renamed to redirectUris; requirePkce field added (optional, defaults to true, set to false for existing confidential clients not supporting PKCE); metadata now stored as individual fields instead of JSON object.

Migration from OIDC Provider plugin: oauthAccessToken table changes

When migrating oauthAccessToken from OIDC Provider plugin, two options exist. Option 1 (simple): delete the existing oauthAccessToken table, requiring users to login again. Option 2 (complex): migrate all tables by converting oauthAccessToken with refreshToken field into new oauthRefreshToken entry with fields token (defaultHasher of refreshToken), expiresAt, clientId, scopes, userId, createdAt, updatedAt; keep oauthAccessToken but reference new oauthRefreshToken with fields token (defaultHasher of accessToken), expiresAt, clientId, scopes, refreshId (oauthRefreshToken.id or undefined if no refreshToken), createdAt, updatedAt.

Migration from MCP plugin endpoints

When migrating from MCP plugin to OAuth Provider plugin, endpoint changes include: /oauth2/authorize (previously /mcp/authorize), /oauth2/token (previously /mcp/token), /oauth2/register (previously /mcp/register), /mcp/get-session removed as not OAuth 2 compliant and replaced with /oauth2/introspect, /.well-known/oauth-protected-resource removed and replaced with helper mcpHandler or server api.oAuth2introspectVerify or resource client verifyAccessToken. Database changes are equivalent to migration from OIDC Provider plugin.

Hash function for migrating plain client secrets

To migrate plain clientSecret values to hashed format during OIDC Provider to OAuth Provider migration, use this function: import { createHash } from '@better-auth/utils/hash'; import { base64Url } from '@better-auth/utils/base64'; const defaultHasher = async (value: string) => { const hash = await createHash('SHA-256').digest(new TextEncoder().encode(value)); const hashed = base64Url.encode(new Uint8Array(hash), { padding: false }); return hashed; };

Give your agent this brain