payload-better-auth
v0.5.6
/

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

DependencyVersion
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)
generateIdSet to 'serial'Do not set
betterAuthStrategyDefaultidType: '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
  1. Remove generateId: 'serial' from your Better Auth config
  2. Change betterAuthStrategy() to use idType: 'text'
  3. Update createSessionHelpers to idType: 'text'
  4. Remove explicit idType: 'number' from adapterConfig
  5. Switch Payload's database adapter to mongooseAdapter
  6. Remove idFieldsAllowlist/idFieldsBlocklist if 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',
  },
})
OptionTypeDescription
payloadClientBasePayload | () => PromisePayload instance or factory function
adapterConfig.enableDebugLogsbooleanEnable 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.

OptionTypeDescription
betterAuthOptionsBetterAuthOptionsYour Better Auth options
skipCollectionsstring[]Collections to skip (default: ['user'])
adminGroupstringAdmin panel group name (default: 'Auth')
accessCollectionConfig['access']Custom access control for generated collections
usePluralbooleanPluralize slugs (default: true)
configureSaveToJWTbooleanAuto-configure saveToJWT (default: true)
firstUserAdminboolean | objectMake first user admin (default: true)
customizeCollection(key, config) => configCustomize 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.

OptionTypeDefaultDescription
createAuth(payload) => AuthrequiredFactory function for Better Auth instance
authBasePathstring'/auth'Base path for auth endpoints
autoRegisterEndpointsbooleantrueAuto-register auth API endpoints
autoInjectAdminComponentsbooleantrueAuto-inject admin components
Full admin options reference
OptionDefaultDescription
admin.disableLogoutButtonfalseDisable logout button injection
admin.disableBeforeLoginfalseDisable BeforeLogin redirect
admin.disableLoginViewfalseDisable 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.requireAllRolesfalseRequire ALL roles instead of any
admin.login.enablePasskeyfalseEnable 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.resetPasswordUrlCustom password reset URL
admin.enableManagementUItrueEnable security management views
API Key Scopes configuration
OptionDefaultDescription
scopesCustom scope definitions
includeCollectionScopesautoInclude auto-generated collection scopes
excludeCollectionsauth collectionsCollections 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.

OptionTypeDescription
usersCollectionstringCollection 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.

PluginPackageNotes
OAuthbetter-authUses accounts collection
Magic Linkbetter-authUses verifications collection
Two-Factorbetter-authAuto-generates twoFactors
API Keysbetter-authAuto-generates apikeys
Organizationsbetter-authAuto-generates orgs, members, invitations
Passkey@better-auth/passkeyAuto-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:

ViewPathPlugin Required
Two-Factor Auth/admin/security/two-factortwoFactor()
API Keys/admin/security/api-keysapiKey()
Passkeys/admin/security/passkeyspasskey()

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