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.6.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 by listing the plugins you need. For typed plugin methods (e.g. client.twoFactor.verifyTotp), list each plugin inline:
ts// src/lib/auth/client.ts
'use client'
import { createAuthClient, twoFactorClient } from '@delmaredigital/payload-better-auth/client'
import { passkeyClient } from '@better-auth/passkey/client'
export const authClient = createAuthClient({
plugins: [twoFactorClient(), passkeyClient()],
})
export const { useSession, signIn, signUp, signOut, twoFactor, passkey } = authClient
Since v0.7.0, payloadAuthPlugins and createPayloadAuthClient() are typed conservatively (BetterAuthClientPlugin[]) to keep generated declaration files portable — they work at runtime, but plugin methods like twoFactor.verifyTotp won't be typed on the returned client. List plugins inline (as above) when you need typed plugin methods.
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 Permissions configuration
| Option | Default | Description |
|---|---|---|
excludeCollections | auth collections | Collections to exclude from permissions UI |
requiredRole | 'admin' | Role required to manage API keys |
ts// Permissions are auto-generated from collections
admin: {
apiKey: {
excludeCollections: ['internal-logs'],
requiredRole: 'admin'
}
}
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(),
],
}
The plugin's API key management UI and permission 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 Permission Enforcement
Enforce API key permissions in your Payload access control using Better Auth's native permission system.
tsimport { requirePermission, requireAllPermissions, allowSessionOrPermission } from '@delmaredigital/payload-better-auth'
access: {
read: requirePermission('posts', 'read'),
create: requirePermission('posts', 'write'),
update: requirePermission('posts', 'write'),
delete: requirePermission('posts', 'write'),
}
// Allow both session auth and API keys
read: allowSessionOrPermission('posts', 'read')
Two permission levels per collection: read (view only) and write (create, update, delete). Write implies read.
Advanced: Custom permission checks
tsimport { requireAnyPermission, requireAllPermissions, requireApiKey } from '@delmaredigital/payload-better-auth'
// Require ANY of these permissions
read: requireAnyPermission([
{ resource: 'posts', action: 'read' },
{ resource: 'pages', action: 'read' },
])
// Require ALL permissions
delete: requireAllPermissions([
{ resource: 'posts', action: 'write' },
{ resource: 'admin', action: 'write' },
])
// Just verify key is valid (no specific permissions)
read: requireApiKey()
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