Better Auth for Payload CMS
Seamless integration between Better Auth and Payload. Use Payload collections as your auth database with auto-generated schemas, session management, and admin UI components.
Installation
Requirements
| Dependency | Version |
|---|---|
payload | ≥ 3.69.0 |
@payloadcms/next | ≥ 3.69.0 |
@payloadcms/ui | ≥ 3.69.0 |
better-auth | ≥ 1.4.0 |
next | ≥ 15.4.8 |
react | ≥ 19.2.1 |
Install
pnpm add @delmaredigital/payload-better-auth better-auth
Optional plugins (install only what you use):
pnpm add @better-auth/passkey
pnpm add @better-auth/api-key
Environment Variables
bash# Required
BETTER_AUTH_SECRET=your-secret-key-min-32-chars
# Optional — only needed if not using the getBaseUrl() helper
BETTER_AUTH_URL=http://localhost:3000
# OAuth Providers (if using social login)
GOOGLE_CLIENT_ID=...
GOOGLE_CLIENT_SECRET=...
Vercel Deployment: Use a getBaseUrl() helper to automatically handle local dev, Vercel preview, and production URLs:
ts// src/lib/auth/getBaseUrl.ts
export function getBaseUrl() {
if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`
if (process.env.BETTER_AUTH_URL) return process.env.BETTER_AUTH_URL
return 'http://localhost:3000'
}
Quick Start
Create Your Auth Configuration
ts// src/lib/auth/config.ts
import type { BetterAuthOptions } from 'better-auth'
export const betterAuthOptions: Partial<BetterAuthOptions> = {
user: {
additionalFields: {
role: { type: 'string', defaultValue: 'user' },
},
},
session: {
expiresIn: 60 * 60 * 24 * 30, // 30 days
},
emailAndPassword: { enabled: true },
}
Create Your Users Collection
ts// src/collections/Users/index.ts
import type { CollectionConfig } from 'payload'
import { betterAuthStrategy } from '@delmaredigital/payload-better-auth'
export const Users: CollectionConfig = {
slug: 'users',
auth: {
disableLocalStrategy: true,
strategies: [betterAuthStrategy()],
},
access: {
read: ({ req }) => {
if (!req.user) return false
if (req.user.role === 'admin') return true
return { id: { equals: req.user.id } }
},
admin: ({ req }) => req.user?.role === 'admin',
},
fields: [
{ name: 'email', type: 'email', required: true, unique: true },
{ name: 'emailVerified', type: 'checkbox', defaultValue: false },
{ name: 'name', type: 'text' },
{ name: 'image', type: 'text' },
{
name: 'role',
type: 'select',
defaultValue: 'user',
options: [
{ label: 'User', value: 'user' },
{ label: 'Admin', value: 'admin' },
],
},
],
}
Plugin-specific fields (e.g., twoFactorEnabled for 2FA) are automatically added to your Users collection by betterAuthCollections().
Configure Payload (Postgres)
For MongoDB, see MongoDB Setup.
ts// src/payload.config.ts
import { buildConfig } from 'payload'
import { postgresAdapter } from '@payloadcms/db-postgres'
import { betterAuth } from 'better-auth'
import {
betterAuthCollections,
createBetterAuthPlugin,
payloadAdapter,
} from '@delmaredigital/payload-better-auth'
export default buildConfig({
collections: [Users],
plugins: [
betterAuthCollections({
betterAuthOptions,
skipCollections: ['user'],
}),
createBetterAuthPlugin({
createAuth: (payload) =>
betterAuth({
...betterAuthOptions,
database: payloadAdapter({ payloadClient: payload }),
advanced: { database: { generateId: 'serial' } },
baseURL: baseUrl,
secret: process.env.BETTER_AUTH_SECRET,
trustedOrigins: [baseUrl],
}),
}),
],
db: postgresAdapter({
pool: { connectionString: process.env.DATABASE_URL },
}),
})
Client-Side Auth
Create a client with the plugins you need. payloadAuthPlugins includes core plugins (twoFactor). Add optional plugins like passkey, apiKey, or stripe directly:
ts// src/lib/auth/client.ts
'use client'
import { createAuthClient, payloadAuthPlugins } from '@delmaredigital/payload-better-auth/client'
import { passkeyClient } from '@better-auth/passkey/client'
export const authClient = createAuthClient({
plugins: [...payloadAuthPlugins, passkeyClient()],
})
export const { useSession, signIn, signUp, signOut, twoFactor, passkey } = authClient
payloadAuthPlugins includes twoFactorClient(). Optional plugins (passkeyClient, apiKeyClient, stripeClient, etc.) are added by you — this way your app only bundles what it uses.
Minimal setup (no passkeys or optional plugins)
tsimport { createPayloadAuthClient } from '@delmaredigital/payload-better-auth/client'
export const authClient = createPayloadAuthClient()
export const { useSession, signIn, signUp, signOut, twoFactor } = authClient
With multiple optional plugins
tsimport { createAuthClient, payloadAuthPlugins } from '@delmaredigital/payload-better-auth/client'
import { passkeyClient } from '@better-auth/passkey/client'
import { apiKeyClient } from '@better-auth/api-key/client'
import { stripeClient } from '@better-auth/stripe/client'
export const authClient = createAuthClient({
plugins: [...payloadAuthPlugins, passkeyClient(), apiKeyClient(), stripeClient({ subscription: true })],
})
Server-Side Session
tsimport { headers } from 'next/headers'
import { getPayload } from 'payload'
import { getServerSession } from '@delmaredigital/payload-better-auth'
export default async function Dashboard() {
const payload = await getPayload({ config })
const headersList = await headers()
const session = await getServerSession(payload, headersList)
if (!session) { redirect('/login') }
return <div>Hello {session.user.name}</div>
}
That's it! The plugin automatically registers auth API endpoints at /api/auth/*, injects admin UI components, and handles session management.
MongoDB Setup
The adapter auto-detects MongoDB and configures itself accordingly. No special adapter configuration is needed.
Key Differences from Postgres
| Postgres (default) | MongoDB | |
|---|---|---|
| ID type | 'number' (SERIAL) | 'text' (ObjectId strings) |
| generateId | Set to 'serial' | Do not set |
| betterAuthStrategy | Default | idType: 'text' |
Full MongoDB Payload config example
ts// src/payload.config.ts
import { mongooseAdapter } from '@payloadcms/db-mongodb'
export default buildConfig({
collections: [Users],
plugins: [
betterAuthCollections({ betterAuthOptions, skipCollections: ['user'] }),
createBetterAuthPlugin({
createAuth: (payload) =>
betterAuth({
...betterAuthOptions,
database: payloadAdapter({ payloadClient: payload }),
// Do NOT set advanced.database.generateId
secret: process.env.BETTER_AUTH_SECRET,
trustedOrigins: ['http://localhost:3000'],
}),
}),
],
db: mongooseAdapter({ url: process.env.DATABASE_URI! }),
})
MongoDB Users collection & session helpers
ts// Users collection
strategies: [betterAuthStrategy({ idType: 'text' })]
// Session helpers
export const { getServerSession, getServerUser } = createSessionHelpers<User>({
idType: 'text',
})
Migrating from Postgres to MongoDB
- Remove
generateId: 'serial'from your Better Auth config - Change
betterAuthStrategy()to useidType: 'text' - Update
createSessionHelperstoidType: 'text' - Remove explicit
idType: 'number'from adapterConfig - Switch Payload's database adapter to
mongooseAdapter - Remove
idFieldsAllowlist/idFieldsBlocklistif set
API Reference
payloadAdapter(config)
Creates a Better Auth database adapter that uses Payload collections. Uses Better Auth's createAdapterFactory for schema-aware transformations.
tspayloadAdapter({
payloadClient: payload,
adapterConfig: {
enableDebugLogs: false,
idType: 'number',
},
})
| Option | Type | Description |
|---|---|---|
payloadClient | BasePayload | () => Promise | Payload instance or factory function |
adapterConfig.enableDebugLogs | boolean | Enable debug logging (default: false) |
adapterConfig.dbType | 'postgres' | 'mongodb' | 'sqlite' | Database type (auto-detected) |
adapterConfig.idType | 'number' | 'text' | ID type (auto-detected) |
Custom collection names
By default, the adapter uses standard collection names. Only use modelName to customize:
tsbetterAuth({
database: payloadAdapter({ payloadClient: payload }),
user: { modelName: 'member' }, // 'users' → 'members'
session: { modelName: 'auth_session' }, // 'sessions' → 'auth_sessions'
})
betterAuthCollections(options)
Payload plugin that auto-generates collections from Better Auth schema.
| Option | Type | Description |
|---|---|---|
betterAuthOptions | BetterAuthOptions | Your Better Auth options |
skipCollections | string[] | Collections to skip (default: ['user']) |
adminGroup | string | Admin panel group name (default: 'Auth') |
access | CollectionConfig['access'] | Custom access control for generated collections |
usePlural | boolean | Pluralize slugs (default: true) |
configureSaveToJWT | boolean | Auto-configure saveToJWT (default: true) |
firstUserAdmin | boolean | object | Make first user admin (default: true) |
customizeCollection | (key, config) => config | Customize generated collections |
Custom Access Caution: The access option completely replaces the default access object. You must handle all access types explicitly.
First User Admin configuration
ts// Customize roles
betterAuthCollections({
betterAuthOptions,
firstUserAdmin: {
adminRole: 'super-admin',
defaultRole: 'member',
roleField: 'userRole',
},
})
// Disable
betterAuthCollections({ betterAuthOptions, firstUserAdmin: false })
Collection customization example
tsbetterAuthCollections({
betterAuthOptions,
customizeCollection: (modelKey, collection) => {
if (modelKey === 'session') {
return { ...collection, hooks: { afterDelete: [cleanupExpiredSessions] } }
}
return collection
},
})
createBetterAuthPlugin(options)
Payload plugin that initializes Better Auth during Payload's onInit.
| Option | Type | Default | Description |
|---|---|---|---|
createAuth | (payload) => Auth | required | Factory function for Better Auth instance |
authBasePath | string | '/auth' | Base path for auth endpoints |
autoRegisterEndpoints | boolean | true | Auto-register auth API endpoints |
autoInjectAdminComponents | boolean | true | Auto-inject admin components |
Full admin options reference
| Option | Default | Description |
|---|---|---|
admin.disableLogoutButton | false | Disable logout button injection |
admin.disableBeforeLogin | false | Disable BeforeLogin redirect |
admin.disableLoginView | false | Disable login view injection |
admin.login.title | 'Login' | Custom login page title |
admin.login.afterLoginPath | '/admin' | Redirect path after login |
admin.login.requiredRole | 'admin' | Required role(s) for admin. null to disable. |
admin.login.requireAllRoles | false | Require ALL roles instead of any |
admin.login.enablePasskey | false | Enable passkey sign-in. 'auto' to detect. |
admin.login.enableSignUp | 'auto' | Enable registration |
admin.login.defaultSignUpRole | 'user' | Default role for new users |
admin.login.enableForgotPassword | 'auto' | Enable forgot password |
admin.login.resetPasswordUrl | — | Custom password reset URL |
admin.enableManagementUI | true | Enable security management views |
API Key Scopes configuration
| Option | Default | Description |
|---|---|---|
scopes | — | Custom scope definitions |
includeCollectionScopes | auto | Include auto-generated collection scopes |
excludeCollections | auth collections | Collections to exclude from scopes |
defaultScopes | [] | Default pre-selected scopes |
requiredRole | 'admin' | Role required to manage API keys |
ts// Custom scopes
admin: {
apiKey: {
scopes: {
'content:read': {
label: 'Read Content',
description: 'View posts and pages',
permissions: { posts: ['read'], pages: ['read'] }
},
},
defaultScopes: ['content:read']
}
}
betterAuthStrategy(options?)
Payload auth strategy for Better Auth session validation.
| Option | Type | Description |
|---|---|---|
usersCollection | string | Collection slug for users (default: 'users') |
idType | 'number' | 'text' | Coerce IDs. Default 'number'. Use 'text' for MongoDB. |
Session Helpers
getServerSession<TUser>(payload, headers)
Get the current session on the server with full type safety.
getServerUser<TUser>(payload, headers)
Shorthand for session.user.
createSessionHelpers<TUser>(options?)
Create typed session helpers. Define once, import everywhere — no generics at call sites.
ts// lib/auth.ts
import { createSessionHelpers } from '@delmaredigital/payload-better-auth'
import type { User } from '@/payload-types'
export const { getServerSession, getServerUser } = createSessionHelpers<User>()
// app/page.tsx — no generic needed
const session = await getServerSession(payload, headersList)
withBetterAuthDefaults(options)
Applies sensible defaults. Sets trustedOrigins: [baseURL] when not explicitly set.
API Key Configuration
API keys require the @better-auth/api-key package. Install it and configure directly in your Better Auth options:
bashpnpm add @better-auth/api-key
tsimport { apiKey } from '@better-auth/api-key'
export const betterAuthOptions = {
plugins: [
apiKey({ enableMetadata: true }), // enableMetadata required for scope display in admin UI
],
}
The plugin's API key management UI and scope enforcement utilities work automatically once the apiKey plugin is configured.
Customization
Role-Based Access Control
The login page checks for the admin role by default. Configure via admin.login.requiredRole.
tsadmin: {
login: {
requiredRole: 'admin', // Single role
requiredRole: ['admin', 'editor', 'moderator'], // Any of these
requiredRole: null, // Disable checking
},
}
Disabling Auto-Injection
tscreateBetterAuthPlugin({
createAuth,
autoRegisterEndpoints: false,
autoInjectAdminComponents: false,
})
Custom admin components
tsadmin: {
loginViewComponent: '@/components/admin/CustomLogin',
logoutButtonComponent: '@/components/admin/CustomLogout',
disableBeforeLogin: true,
}
Manual API route (advanced)
ts// src/app/api/auth/[...all]/route.ts
import type { PayloadWithAuth } from '@delmaredigital/payload-better-auth'
export async function GET(request: NextRequest) {
const payload = (await getPayload({ config })) as PayloadWithAuth
return payload.betterAuth.handler(request)
}
export async function POST(request: NextRequest) {
const payload = (await getPayload({ config })) as PayloadWithAuth
return payload.betterAuth.handler(request)
}
Access Control Helpers
Pre-built access control functions for common authorization patterns.
tsimport {
isAdmin, isAdminField, isAdminOrSelf,
hasRole, requireAllRoles,
isAuthenticated, isAuthenticatedField,
canUpdateOwnFields,
} from '@delmaredigital/payload-better-auth'
export const Posts: CollectionConfig = {
slug: 'posts',
access: {
read: isAuthenticated(),
create: hasRole(['editor', 'admin']),
update: hasRole(['editor', 'admin']),
delete: requireAllRoles(['admin', 'content-manager']),
},
fields: [{
name: 'internalNotes',
type: 'textarea',
access: { read: isAdminField() },
}],
}
Self-access patterns
tsaccess: {
read: isAdminOrSelf({ adminRoles: ['admin'] }),
update: canUpdateOwnFields({
allowedFields: ['name', 'image', 'password'],
userSlug: 'users',
requireCurrentPassword: true,
}),
delete: isAdmin({ adminRoles: ['admin'] }),
}
Utility functions
tsimport { normalizeRoles, hasAnyRole, hasAllRoles } from '@delmaredigital/payload-better-auth'
const roles = normalizeRoles(user.role)
hasAnyRole(user, ['admin', 'editor'])
hasAllRoles(user, ['admin', 'editor'])
API Key Scope Enforcement
Enforce API key scopes in your Payload access control.
tsimport { requireScope, requireAllScopes, allowSessionOrScope } from '@delmaredigital/payload-better-auth'
access: {
read: requireScope('posts:read'),
create: requireScope('posts:write'),
delete: requireAllScopes(['posts:delete', 'admin:write']),
}
// Allow both session auth and API keys
read: allowSessionOrScope('posts:read')
Wildcard scopes: 'posts:*' matches all post permissions. '*' matches everything.
Manual API key validation
tsimport { validateApiKey, hasScope } from '@delmaredigital/payload-better-auth'
const keyInfo = await validateApiKey(req)
if (!keyInfo) return Response.json({ error: 'Invalid' }, { status: 401 })
if (!hasScope(keyInfo.scopes, 'custom:action')) { /* 403 */ }
Plugin Compatibility
The adapter uses Better Auth's createAdapterFactory which is schema-aware — it automatically supports all Better Auth plugins.
| Plugin | Package | Notes |
|---|---|---|
| OAuth | better-auth | Uses accounts collection |
| Magic Link | better-auth | Uses verifications collection |
| Two-Factor | better-auth | Auto-generates twoFactors |
| API Keys | better-auth | Auto-generates apikeys |
| Organizations | better-auth | Auto-generates orgs, members, invitations |
| Passkey | @better-auth/passkey | Auto-generates passkeys |
Adding join fields for relationships
Payload uses join fields to establish queryable parent-to-child relationships. When a plugin creates a model with a foreign key, add a join field to the parent collection:
ts// API Keys join
{ name: 'apiKeys', type: 'join', collection: 'apikeys', on: 'user' }
// Two-Factor join
{ name: 'twoFactor', type: 'join', collection: 'twoFactors', on: 'user' }
// Memberships join
{ name: 'memberships', type: 'join', collection: 'members', on: 'user' }
Cascade delete (cleanup orphaned records)
tsuser: {
deleteUser: {
enabled: true,
afterDelete: async (user) => {
const collections = ['sessions', 'accounts', 'apikeys', 'passkeys', 'twoFactors']
for (const col of collections) {
try {
await payload.delete({ collection: col, where: { user: { equals: user.id } } })
} catch {} // Collection may not exist
}
},
},
}
UI Components
User Registration
The LoginView automatically detects if user registration is available by checking Better Auth's sign-up endpoint. If your Better Auth config has emailAndPassword.enabled: true (and not disableSignUp: true), the "Create account" link appears automatically.
No configuration needed for most cases — it just works.
Password Reset
The "Forgot password?" link appears automatically when the reset endpoint is available.
Standalone components
tsimport { ForgotPasswordView, ResetPasswordView } from '@delmaredigital/payload-better-auth/components/auth'
<ForgotPasswordView
logo={<MyLogo />}
title="Forgot Password"
loginPath="/admin/login"
/>
<ResetPasswordView
logo={<MyLogo />}
title="Reset Password"
afterResetPath="/admin/login"
minPasswordLength={8}
/>
Two-Factor Authentication
The LoginView handles 2FA inline automatically. When a user with 2FA enabled signs in, the form transitions to a TOTP code input.
Standalone components for custom flows
tsimport { TwoFactorSetupView, TwoFactorVerifyView } from '@delmaredigital/payload-better-auth/components/twoFactor'
<TwoFactorSetupView logo={<MyLogo />} afterSetupPath="/admin" />
<TwoFactorVerifyView logo={<MyLogo />} afterVerifyPath="/admin" />
Handling 2FA in custom login forms
Important: Always check result.data?.twoFactorRedirect after signIn.email(). Without this, users with 2FA enabled appear to log in but won't actually be authenticated.
tsconst result = await signIn.email({ email, password })
if (result.data?.twoFactorRedirect) {
// Show TOTP verification form
setTwoFactorRequired(true)
return
}
// Then verify with:
await twoFactor.verifyTotp({ code: totpCode })
Passkeys
ts// Enable in LoginView
admin: { login: { enablePasskey: true } }
// Or use standalone button
import { PasskeySignInButton } from '@delmaredigital/payload-better-auth/components/passkey'
<PasskeySignInButton
onSuccess={(user) => router.push('/dashboard')}
onError={(error) => setError(error)}
/>
Passkey registration & management
ts// Registration button
import { PasskeyRegisterButton } from '@delmaredigital/payload-better-auth/components/passkey'
<PasskeyRegisterButton passkeyName="My MacBook" onSuccess={refetch} />
// Full management UI
import { PasskeysManagementClient } from '@delmaredigital/payload-better-auth/components/passkey'
<PasskeysManagementClient title="Manage Passkeys" />
// Or use the auth client directly (requires passkeyClient() in your client plugins)
await authClient.passkey.addPasskey({ name: 'My Device' })
await authClient.passkey.listUserPasskeys()
await authClient.passkey.deletePasskey({ id: passkeyId })
Security Management UI
Auto-injected management views based on enabled plugins:
| View | Path | Plugin Required |
|---|---|---|
| Two-Factor Auth | /admin/security/two-factor | twoFactor() |
| API Keys | /admin/security/api-keys | apiKey() |
| Passkeys | /admin/security/passkeys | passkey() |
Recipes
Auto-create organization on user signup
Use a lazy auth instance singleton to call auth.api.createOrganization() from database hooks (so organizationHooks fire properly).
1. Create an auth instance singleton
ts// src/lib/auth/instance.ts
let authInstance: AuthInstance | null = null
export function setAuthInstance(auth: AuthInstance) { authInstance = auth }
export function getAuthInstance() {
if (!authInstance) throw new Error('Auth not initialized')
return authInstance
}
2. Store after creation
tscreateBetterAuthPlugin({
createAuth: (payload) => {
const auth = betterAuth({ ...betterAuthOptions, database: payloadAdapter({ payloadClient: payload }) })
setAuthInstance(auth)
return auth
},
})
3. Use in database hooks
tsdatabaseHooks: {
user: {
update: {
after: async (user, ctx) => {
if (!user.emailVerified) return
const existing = await ctx?.context?.adapter?.findOne({
model: 'member', where: [{ field: 'userId', value: user.id }],
})
if (existing) return
const auth = getAuthInstance()
await auth.api.createOrganization({
body: { name: `${user.name}'s Workspace`, slug: generateSlug(user.name), userId: user.id },
})
},
},
},
}
Always use auth.api.createOrganization() instead of raw adapter calls. The adapter bypasses organizationHooks entirely.
Types
tsimport type {
PayloadWithAuth,
PayloadRequestWithBetterAuth,
BetterAuthReturn,
CollectionHookWithBetterAuth,
EndpointWithBetterAuth,
} from '@delmaredigital/payload-better-auth'
// Generated schema types
import type {
User, BetterAuthSession, Account,
Apikey, Passkey, Organization, Member, TwoFactor,
} from '@delmaredigital/payload-better-auth'
Regenerate types after adding plugins:
pnpm generate:types
MIT License — @delmaredigital/payload-better-auth