A Payload CMS plugin that integrates Better Auth for seamless user authentication and management.
- Better Auth as Single Source of Truth — All user operations managed through Better Auth
- SecondaryStorage Pattern — Pluggable storage with SQLite (dev) or Redis (production)
- Instant Session Validation — Payload reads sessions directly from shared storage (no HTTP calls)
- Automatic Session Invalidation — Logout in Better Auth immediately invalidates Payload sessions
- Horizontal Scaling — Redis adapter supports multiple instances
- Timestamp-based Coordination — Automatic reconciliation without race conditions
- Custom Login UI — Replaces Payload's default login with Better Auth authentication
- Auto-extending Users Collection — Plugin extends your existing users collection with auth integration
- Better Auth Collections — Dedicated collections for each auth method (email-password, magic-link)
pnpm add payload-better-auth better-authRequirements: Node.js 22+ (for native SQLite), Better Auth 1.4.10+, Payload CMS 3.37.0+
// lib/syncAdapter.ts
import { DatabaseSync } from 'node:sqlite'
import { createSqliteStorage } from 'payload-better-auth/storage'
const db = new DatabaseSync('.sync-state.db')
export const storage = createSqliteStorage({ db })
// lib/eventBus.ts
import { DatabaseSync } from 'node:sqlite'
import { createSqlitePollingEventBus } from 'payload-better-auth/eventBus'
const db = new DatabaseSync('.event-bus.db')
export const eventBus = createSqlitePollingEventBus({ db })// lib/auth.ts
import { betterAuth } from 'better-auth'
import { admin, apiKey } from 'better-auth/plugins'
import Database from 'better-sqlite3'
import { payloadBetterAuthPlugin } from 'payload-better-auth'
import type { User } from './payload-types' // Generated Payload types
import buildConfig from './payload.config.js'
import { eventBus } from './eventBus'
import { storage } from './syncAdapter'
export const auth = betterAuth({
database: new Database(process.env.BETTER_AUTH_DB_PATH || './better-auth.db'),
secret: process.env.BETTER_AUTH_SECRET,
emailAndPassword: { enabled: true },
plugins: [
admin(),
apiKey(),
payloadBetterAuthPlugin<User>({
payloadConfig: buildConfig,
token: process.env.RECONCILE_TOKEN,
storage, // Shared with Payload plugin
eventBus, // Shared with Payload plugin
// Map Better Auth user data to your Payload user fields
mapUserToPayload: (baUser) => ({
email: baUser.email ?? '',
name: baUser.name ?? '',
// Add defaults for any required fields in your users collection
}),
}),
],
})// payload.config.ts
import { buildConfig } from 'payload'
import { betterAuthPayloadPlugin } from 'payload-better-auth'
import { eventBus } from './lib/eventBus'
import { storage } from './lib/syncAdapter'
export default buildConfig({
collections: [
// Optional: Define your own users collection - it will be auto-extended
{
slug: 'users',
fields: [
{ name: 'email', type: 'email', required: true },
{ name: 'name', type: 'text' },
// Add your custom fields...
],
// Your access rules are preserved and OR'd with BA sync access
access: {
read: ({ req }) => Boolean(req.user),
},
},
// ... other collections
],
plugins: [
betterAuthPayloadPlugin({
betterAuthClientOptions: {
externalBaseURL: process.env.NEXT_PUBLIC_SERVER_URL || 'http://localhost:3000',
internalBaseURL: process.env.INTERNAL_SERVER_URL || 'http://localhost:3000',
},
storage, // Shared with Better Auth plugin
eventBus, // Shared with Better Auth plugin
collectionPrefix: '__better_auth', // optional, this is the default
debug: false, // Enable to see BA collections in admin panel
// Optional: Custom access for BA collections
baCollectionsAccess: {
read: ({ req }) => req.user?.role === 'admin',
delete: ({ req }) => req.user?.role === 'admin',
},
}),
],
// ... rest of your config
})If you don't define a users collection, a minimal one will be created automatically.
BETTER_AUTH_SECRET=your-secret-min-32-chars
BETTER_AUTH_DB_PATH=./better-auth.db
BA_TO_PAYLOAD_SECRET=your-sync-secret
RECONCILE_TOKEN=your-api-token
PAYLOAD_SECRET=your-payload-secret
DATABASE_URI=file:./payload.dbRun Better Auth and Payload as separate processes. When both run in the same process (e.g. Better Auth and Payload in one Next.js app), this plugin can trigger interaction loops between the two (reconcile → Payload hooks → sync → Better Auth → reconcile), which can cause noticeable performance issues. Running Better Auth and Payload in separate processes avoids these loops and is the recommended setup for both development and production.
Your access rules are preserved and combined with Better Auth's internal access. BA sync operations (signed with BA_TO_PAYLOAD_SECRET) always pass.
// Example: Allow admins to manage all users, regular users to read only
{
slug: 'users',
access: {
read: () => true, // everyone can read
create: ({ req }) => req.user?.role === 'admin', // only admins create manually
update: ({ req, id }) => req.user?.role === 'admin' || req.user?.id === id,
delete: ({ req }) => req.user?.role === 'admin',
},
}
// Result: BA sync operations pass via signature, manual operations use your rulesThe plugin creates two additional collections for auth method data:
__better_auth_email_password- Email/password account data__better_auth_magic_link- Magic link account data
These collections are locked down by default (only BA sync agent can access). You can optionally open up read and delete access.
Note: These collections are hidden from the admin panel by default. Set
debug: truein the Payload plugin options to make them visible under the "Better Auth (DEBUG)" group for troubleshooting.
For multi-server or geo-distributed deployments, use the Redis storage and EventBus adapters:
// lib/syncAdapter.ts
import { createRedisStorage } from 'payload-better-auth/storage'
import Redis from 'ioredis'
const redis = new Redis(process.env.REDIS_URL)
export const storage = createRedisStorage({ redis })
// lib/eventBus.ts
import { createRedisEventBus } from 'payload-better-auth/eventBus'
import Redis from 'ioredis'
// Redis Pub/Sub requires separate connections for publishing and subscribing
const publisher = new Redis(process.env.REDIS_URL)
const subscriber = new Redis(process.env.REDIS_URL)
export const eventBus = createRedisEventBus({ publisher, subscriber })Then pass the same instances to both plugins:
// In Better Auth config:
payloadBetterAuthPlugin({
storage,
eventBus,
payloadConfig: buildConfig,
token: process.env.RECONCILE_TOKEN,
mapUserToPayload: (baUser) => ({ ... }),
})
// In Payload config:
betterAuthPayloadPlugin({
storage,
eventBus,
betterAuthClientOptions: { ... },
})For detailed configuration options, API endpoints, architecture details, and production considerations, see the MANUAL.md.
# Install dependencies
pnpm install
# Reset databases and run migrations
pnpm reset
# Start development server
pnpm devThe dev server starts at http://localhost:3000 with a mail server at port 1080.
To test the Redis integration locally:
# Start Redis container
pnpm docker:redis
# Run dev server with Redis (instead of SQLite)
pnpm dev:redis
# Stop Redis when done
pnpm docker:redis:stopThis project uses Husky for Git hooks:
- pre-commit: Builds the plugin and stages
dist/, blocks manual version changes - pre-push: Runs lint, typecheck, and tests before pushing
- commit-msg: Validates commit messages follow Conventional Commits
When you're happy with your changes, just commit — the build is handled for you!
This project uses semantic-release for automated versioning. Do not manually edit the version field in package.json — it will be rejected by the pre-commit hook.
Versions are determined automatically from your commit messages:
| Commit Type | Version Bump | Example |
|---|---|---|
fix: |
Patch (1.0.0 → 1.0.1) | fix: resolve login redirect bug |
feat: |
Minor (1.0.0 → 1.1.0) | feat: add OAuth provider support |
feat!: or BREAKING CHANGE: |
Major (1.0.0 → 2.0.0) | feat!: redesign auth API |
When you push to main, the CI will automatically:
- Analyze commits since the last release
- Determine the next version
- Update
package.jsonandCHANGELOG.md - Create a Git tag and GitHub Release
# Latest
pnpm add github:benjaminpreiss/payload-better-auth
# Specific version
pnpm add github:benjaminpreiss/payload-better-auth#v1.2.0| Script | Description |
|---|---|
pnpm dev |
Start dev server with mail server |
pnpm dev:redis |
Start dev server with Redis (instead of SQLite) |
pnpm docker:redis |
Start Redis container via Docker Compose |
pnpm docker:redis:stop |
Stop Redis container |
pnpm build |
Build the plugin |
pnpm reset |
Reset databases and run all migrations |
pnpm test |
Run all tests |
pnpm lint |
Run ESLint |
pnpm typecheck |
Run TypeScript type checking |
pnpm generate:types |
Generate Payload types |
├── src/ # Plugin source code
│ ├── storage/ # SecondaryStorage implementations (SQLite, Redis)
│ ├── eventBus/ # EventBus implementations (SQLite polling, Redis Pub/Sub)
│ ├── better-auth/ # Better Auth integration & reconcile queue
│ ├── collections/ # Payload collections (Users, BetterAuth)
│ ├── components/ # React components (Login UI)
│ ├── payload/ # Payload plugin
│ ├── shared/ # Shared utilities (deduplicated logger)
│ └── exports/ # Client/RSC exports
├── dev/ # Development environment
│ ├── app/ # Next.js app
│ ├── lib/ # Dev configuration
│ └── tests/ # Test files
└── dist/ # Built output
MIT