payload-better-auth
v0.13.1
/

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.

Upgrading Better Auth

Generated collections follow the installed Better Auth schema. With the SQL adapters (Postgres/SQLite), after any better-auth bump, including a patch, generate a migration and read the diff before deploying:

bashpayload migrate:create

A new NOT NULL column on a populated table needs a backfill before enforcing the constraint; a proposed column drop needs you to confirm nothing still reads it.

payload migrate:create compares against the last migration's snapshot, rather than the live database. An empty migration does not prove the database matches; check the actual columns if in doubt. Dev push mode applies the same changes without asking.

This repo keeps schema snapshots and runs a daily CI canary against the latest Better Auth to catch drift. The CHANGELOG documents the Better Auth schema changes this repo detects; review it alongside your own migration diff.

Upgrading to 0.12? Array-typed fields changed shape on disk. Use 0.12.1 or later.

Releases up to 0.11.3 reported supportsArrays: false to Better Auth, so every string[] / number[] value was JSON.stringify'd on its way into Payload — those columns hold '["a","b"]' where an array belongs. 0.12.0 stores arrays natively, so existing rows need converting once.

If you don't use the oauth-provider plugin and have no array-typed additionalFields, there is nothing to do — nothing else in Better Auth uses an array field.

Otherwise: migrate, then verify, then remove any workaround of your own — in that order.

tsimport { migrateStringifiedArrays } from '@delmaredigital/payload-better-auth'

const results = await migrateStringifiedArrays({
  payload,
  betterAuthOptions,
  dryRun: true, // drop this once the report looks right
})
console.table(results)

Other options: usePlural (must match the adapter; default true), batchSize (rows per page; default 100, honoured on Postgres since 0.12.2), and onProgress (called once per converted document).

On 0.12.0 this reported converted: 0 against databases that were not clean. Payload writes these fields to a jsonb column, so a stringified value is stored as a jsonb string; on read node-postgres parses the jsonb and drizzle's PgJsonb.mapFromDriverValue parses it a second time, so the stored string reaches Payload as an array and is indistinguishable from a native one. 0.12.1 censuses the stored shape in SQL instead. If you ran the 0.12.0 migration, re-run it, and check each row reports observedVia: 'stored-shape'.

Verify against the database before dropping any tolerant parsing you added — SELECT jsonb_typeof(scopes), count(*) FROM oauth_access_tokens GROUP BY 1; should show no string rows. Removing a tolerant parse while unconverted rows remain is what turns this into an incident: raw SQL carries no column type, so it bypasses drizzle's mapper and still sees the string. Better Auth's own reads go through Payload and are laundered, so /oauth2/authorize keeps working — the damage is confined to code that queries these columns directly.

Better Auth 1.7.3 reverted the issuer account schema. Accounts are keyed on (providerId, accountId) again, new rows no longer write issuer, and the generated accounts collection no longer emits the field or its unique index. If you applied the 0.11 migration below, relax it before upgrading past Better Auth 1.7.2 or every sign-up and account link fails on the NOT NULL constraint:

sqlALTER TABLE accounts ALTER COLUMN issuer DROP NOT NULL;
DROP INDEX accounts_issuer_account_id_idx; -- use the name Payload generated

payload migrate:create then proposes dropping the column and index; applying it is the full cleanup. The backfill guidance in the next callout describes Better Auth 1.7.0–1.7.2 only. See the Better Auth upgrade guide. Separately, Better Auth 1.7.7 fixed a critical Magic Link account takeover (GHSA-965c-763c-88jm); run 1.7.7 or later.

Upgrading to 0.11? Better Auth 1.7 is now required, and it needs a database migration.

1. Upgrade the peers together — better-auth@^1.7, plus @better-auth/api-key / @better-auth/passkey at the same major if you use them. 1.6 is no longer supported: 1.7 requires two new adapter methods, and a 1.6 install throws at runtime.

2. Generate the migration, then edit it to backfill issuer. Better Auth 1.7 keys provider identities on (issuer, accountId) instead of providerId, so the accounts collection gains a required issuer field plus a unique index. payload migrate:create emits an ADD COLUMN … NOT NULL that fails on a populated table — split it into add-nullable → backfill → enforce, and check for duplicate (issuer, account_id) pairs before adding the index. The full SQL is in the README.

⚠️ local:oauth:<providerId> is the fallback, not the rule. It applies only where a provider declares no issuer of its own. In Better Auth 1.7.1 seven built-ins DO declare one and must not get the synthetic form: google (https://accounts.google.com), facebook (https://www.facebook.com), apple (https://appleid.apple.com), line, cognito, paybin and microsoft — plus every generic-OAuth/OIDC provider (Okta, Auth0, Keycloak). Email/password rows take local:credential. Read the value rather than guessing it:

bashnode -e "import('better-auth/social-providers').then(m => console.log(m.google({clientId:'x',clientSecret:'y'}).accountIssuer))"

Backfilling those rows with local:oauth:… files them under a key Better Auth never queries. Sign-in doesn't find the row, takes the new-identity path and writes a second account row — the unique index permits it because the pair differs. Nothing fails, and the damage is silent until a user is asked to link an account they already have.

⚠️ Microsoft Entra ID needs a row-by-row backfill, and its account_id moves too. Its issuer is per-tenant (https://login.microsoftonline.com/<tid>/v2.0), so there is no constant to write — and 1.7 keys the subject on the oid claim where 1.6 stored sub. Both values are in the id_token already on the row: decode the claims segment (base64url) and take iss and oid. Decode only — there is no signature to check, because this reads a claim out of your own database rather than a token presented by a caller.

3. Apply the migration and verify sign-in for each provider you support. No application-code changes are required for the common setup — the adapter, generated collections and admin UI absorb the rest of 1.7. If you use OAuth JWT bearer auth, database joins, or a proxy with a dynamic baseURL, see the CHANGELOG for the smaller items.

📦 Coming from 0.10 or earlier? One more behavioral change before you get to the above: since 0.10, secret fields on the plugin's managed collections are locked by default (secureSecretFields on betterAuthCollections()). Session tokens, TOTP secrets and backup codes, stored OAuth tokens, hashed passwords and API keys, JWKS private keys and OAuth client secrets are no longer readable through Payload's REST/GraphQL API. That only matters if you read them there, or via a Local API call passing overrideAccess: false — opt out with secureSecretFields: false, or unlock per model. Every release from 0.7 to 0.9 carried its own breaking change on top of that: read the CHANGELOG and apply each migration between your version and this one.

Installation

Requirements

DependencyVersion
payload≥ 3.90.0
@payloadcms/next≥ 3.90.0
@payloadcms/ui≥ 3.90.0
better-auth≥ 1.7.7 < 2 — 1.7.7 fixes a critical Magic Link account takeover; 1.6 is not supported
next≥ 16.4.1
react≥ 19.2.1
@better-auth/api-key≥ 1.7.7 < 2 — optional, only for API keys
@better-auth/passkey≥ 1.7.7 < 2 — optional, only for passkeys
Node≥ 22.23.4 — Node 20 is end-of-life and unpatched

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: {
      // input: false keeps `role` server-only — clients cannot set it at sign-up.
      role: { type: 'string', defaultValue: 'user', input: false },
    },
  },
  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().

Every additionalFields entry needs a matching field here. An array-typed one (string[] / number[]) needs a field that stores an array — json, or select with hasMany: true — because the adapter writes real arrays, not serialized ones.

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>
}

Sessions slide the way Better Auth documents (session.updateAge): once the refresh window is reached, getSession() extends the session row and issues a refreshed cookie. betterAuthStrategy forwards that Set-Cookie on every Payload REST and GraphQL request, so the browser's cookie moves with the database. Where there is no response to carry a cookie — server components, getServerSession(), payload.auth({ headers }) — the session is read without refreshing it, so the two never drift apart. To let a Local API call refresh the session, tell Payload it may set headers and forward what it hands back:

ts// app/api/whoami/route.ts
export async function GET(req: Request) {
  const payload = await getPayload({ config })
  const { user, responseHeaders } = await payload.auth({ headers: req.headers, canSetHeaders: true })
  return Response.json({ user }, { headers: responseHeaders })
}

Since v0.13.0. Before that, the refreshed cookie was dropped and anyone using only Payload (the admin included) was logged out after expiresIn regardless of activity.

That's it! The plugin automatically registers auth API endpoints at /api/auth/*, injects admin UI components, and handles session management.

Using a non-default API route? The plugin mounts Better Auth at routes.api + authBasePath, and Better Auth's own router 404s any request outside its basePath (default /api/auth). If your Payload config sets routes: { api: '/api/payload' }, tell Better Auth where it lives:

ts// inside createAuth
betterAuth({
  basePath: '/api/payload/auth', // <routes.api> + <authBasePath>
  // ...
})

The admin components pick up routes.api automatically, and the plugin logs an error at startup naming the exact value to set if basePath doesn't match the mount.

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'
})

Atomic operations

Better Auth 1.7 requires every database adapter to implement two atomic primitives, and calls them on paths you are almost certainly using:

PrimitiveUsed by
consumeOneSingle-use credentials — email verification, password reset, magic links, email OTP, device-authorization codes
incrementOneGuarded counters — API-key quota and rate limits, two-factor backup codes, team member counts

Payload's Local API has no DELETE … RETURNING or SET n = n + d, so the adapter implements both as read-then-write and narrows the race window rather than eliminating it:

  • consumeOne reads the row, then deletes it by id. The row is returned only if our own delete removed it — if a concurrent caller got there first, Payload raises a 404 and the adapter returns null. That is what keeps a magic link or reset token single-use.
  • incrementOne reads the row, then writes with a guard that re-asserts both Better Auth's own precondition (e.g. remaining > 0) and the counter values the write was computed from. A racing writer's update matches no row, so the loser re-reads and retries (up to five times) instead of clobbering the winner.

The limit: Payload resolves a where by finding rows and then mutating them, so a narrow window remains between its internal read and write. Under heavy concurrency on the same row, a quota decrement can be lost or a token consumed twice. This matches the guarantee Better Auth 1.6 provided for these flows and is fine for typical traffic — but if you need strict cross-process guarantees for API-key quota, enforce it at the database with a CHECK constraint or a unique index rather than relying on the adapter alone.

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)
roleFieldstringUser property that holds the role(s), read by every role check in the plugin (default: 'role'). See Naming the role field.
firstUserAdminboolean | objectMake first user admin (default: true)
acknowledgeRoleGuardDisabledbooleanSilence the startup warning printed when firstUserAdmin: false turns the role-forcing guard off (default: false)
secureSecretFieldsboolean | objectDeny API/admin access to secret fields — session tokens, TOTP secrets, stored OAuth tokens, etc. (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', // firstUserAdmin.roleField also works
})

// Disable
betterAuthCollections({ betterAuthOptions, firstUserAdmin: false })

// Disable, and silence the startup warning once creation access is locked down
betterAuthCollections({
  betterAuthOptions,
  firstUserAdmin: false,
  acknowledgeRoleGuardDisabled: true,
})

Disabling firstUserAdmin removes the guard that forces a safe role on new users, so the plugin warns at startup. Before acknowledging, make sure the users collection's access.create and the role field's access.create reject anonymous and non-admin callers — otherwise POST /api/users can create a user with any role. The flag silences that one warning and changes nothing else.

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. Only injected when disableLoginView keeps Payload's own login view — it renders inside that view.
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.resetPasswordUrl—Custom password reset URL
admin.login.enablePassword'auto'Show the email/password field. false (or auto-detect) hides it for passwordless-only sign-in.
admin.login.enableMagicLink'auto'Enable magic-link ("email me a link") sign-in. Requires the Better Auth magicLink() plugin.
admin.login.enableEmailOtp'auto'Enable email-OTP ("email me a code") sign-in. Requires the Better Auth emailOTP() plugin.
admin.login.enableTwoFactorBackupCode'auto'Offer "use a backup code" on the login form's two-factor step.
admin.login.enableTwoFactorEmailOtp'auto'Offer "email me a code" on the two-factor step. Requires the twoFactor() plugin's otpOptions. A ceiling only — the step follows the factors Better Auth reports for the signed-in user, so it opens on the emailed code when they have no authenticator, and never offers a factor they don't hold.
admin.login.magicLinkCallbackURL—Where the emailed magic link returns. Defaults to afterLoginPath.
admin.login.enableSocialfalseShow social/OAuth provider buttons for every provider Better Auth resolved — built-in socialProviders and genericOAuth() (OIDC/SSO) providers. true shows them all; an array is an allowlist of provider ids (a generic provider's providerId). There is no 'auto'.
admin.login.socialCallbackURL—Where a successful social sign-in returns. Defaults to the login page so the role gate runs; errors always return there.
admin.enableManagementUItrueEnable security management views
API Key Permissions configuration
OptionDefaultDescription
excludeCollectionsauth collectionsCollections 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.

OptionTypeDescription
usersCollectionstringCollection slug for users (default: 'users')
membersCollectionstringCollection slug for organization members, used for the organization role lookup (default: 'members')
apiKeysCollectionstringCollection slug for API keys, used to read a key's scopes without spending its quota. Must match the generated slug: 'apikeys', or 'apikey' with usePlural: false (default: 'apikeys')
idType'number' | 'text'Coerce IDs. Default 'number'. Use 'text' for MongoDB.

Session refresh. When Payload lets the strategy set headers (REST and GraphQL requests), it forwards Better Auth's refreshed Set-Cookie through Payload's responseHeaders. When it can't (canSetHeaders: false, e.g. admin server renders), the session is read with disableRefresh. See sliding sessions.

API keys are sent as x-api-key. That is what Better Auth's api-key plugin reads by default. Authorization: Bearer <key> is only treated as an API key if you configure the plugin's apiKeyHeaders or customAPIKeyGetter; otherwise such a request authenticates as nobody.

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.

Both are read-only: they pass disableRefresh, because a server component has no response to carry a refreshed cookie. See sliding sessions.

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
  },
}

Naming the role field

Role checks read user.role by default. To store roles under another property, such as a roles array, set roleField once on betterAuthCollections(). The plugin publishes it on config.custom.betterAuth.roleField, where every role check reads it: the first-user-admin guard, the saveToJWT field list, the access helpers, the admin login role gate and the API-key management gate.

ts// Better Auth
user: { additionalFields: { roles: { type: 'string[]', input: false } } }

// Users collection
{ name: 'roles', type: 'json' }

// Payload config
betterAuthCollections({ betterAuthOptions, roleField: 'roles' })
  • firstUserAdmin.roleField is the older spelling of the same setting and still works. Setting both to different values throws at startup.
  • hasAnyRole(user, roles, 'roles') and hasAllRoles take the field as a third argument; they have no config to read. The other access helpers read it from the request's Payload config, and accept roleField to override.
  • Types: WithRoleField<User, 'roles', string[]> moves the generated role property to the configured name.
  • firstUserAdminHooks() runs inside Better Auth, outside the Payload config, so pass it roleField directly if you use it.

Only the column name differs? Better Auth's fieldName mapping (role: { type: 'string', fieldName: 'roles' }) renames where the value is stored, and the adapter handles it. In this plugin the stored name is also the Payload field name, so Payload-side checks see user.roles while Better Auth's session user keeps role — the admin login gate translates roleField to the session key on its own. Use fieldName for a column rename under Better Auth's key; use roleField when the property itself has another name.

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.

PluginPackageNotes
OAuthbetter-authUses accounts collection
Magic Linkbetter-authUses verifications collection · login UI supported
Email OTPbetter-authUses verifications collection · login UI supported
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 whether registration is available from your Better Auth configuration. If 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 second-factor step.

The step offers the factors that user actually has. Better Auth reports them on the sign-in response, so someone with no authenticator set up lands on the emailed code rather than a TOTP input they can't satisfy. "Use a backup code" is offered whenever the twoFactor plugin is active — the escape hatch for a lost authenticator — and "email me a code" when the plugin is configured with otpOptions.sendOTP, with resends on a 30-second cooldown. Both are detected server-side; admin.login.enableTwoFactorBackupCode and admin.login.enableTwoFactorEmailOtp are ceilings over that detection.

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" />

// As an admin view, prefer the server wrapper — it detects whether the
// account has a password and skips the confirmation step when it doesn't:
import { TwoFactorSetupViewWrapper } from '@delmaredigital/payload-better-auth/rsc'
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) {
  // twoFactorMethods is the factors THIS user holds: ['totp'], ['otp'],
  // both, or [] when only backup codes are left. Offer from that list —
  // defaulting to TOTP strands anyone who never set up an authenticator.
  setTwoFactorMethods(result.data.twoFactorMethods)
  return
}

// Then verify with whichever factor they chose:
await twoFactor.verifyTotp({ code })        // authenticator app
await twoFactor.verifyBackupCode({ code })  // backup code
await twoFactor.sendOtp()                   // then verifyOtp({ code })

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 })

Passwordless Sign-In (Magic Link & Email OTP)

The LoginView auto-detects the Better Auth magicLink() and emailOTP() plugins and surfaces "Email me a link" / "Email me a code" options on the admin login. If your Better Auth config sets emailAndPassword.enabled: false, the password field is hidden automatically and a passwordless method becomes the primary action.

ts// Each option defaults to 'auto' (show iff the matching plugin is installed).
// Force on/off or hide the password field for passwordless-only sign-in:
admin: {
  login: {
    enableMagicLink: 'auto',   // requires magicLink() plugin
    enableEmailOtp: 'auto',    // requires emailOTP() plugin
    enablePassword: false,     // hide password field (passwordless-only)
    magicLinkCallbackURL: '/admin',
    enableSocial: ['google', 'github'], // opt-in; socialProviders or genericOAuth ids
  },
}

Server-side wiring stays yours: configure the magicLink() / emailOTP() plugins and their email senders in your Better Auth instance, and the UI lights up automatically. Added in v0.7.5.

Social Sign-In

Set admin.login.enableSocial to true (every resolved provider) or an array of provider ids to surface "Continue with …" buttons on the admin login page. Configure socialProviders in your Better Auth instance as usual — the UI reflects what Better Auth actually resolved, so a provider you switched off with enabled: false never gets a button that would fail on click.

Generic OAuth / OIDC (SSO) providers work too. Since Better Auth 1.7, genericOAuth() registers its providers as first-class social providers, so a Keycloak, Zitadel, Okta, Auth0 or Entra ID provider appears alongside the built-in ones with no custom login view. Allowlist it by its providerId, and give it a name to control the button label — { providerId: 'zitadel', name: 'Company SSO' } renders as "Continue with Company SSO". Fixed in v0.11.3 — before that, generic providers were filtered out.

plugins: [
  genericOAuth({
    config: [
      keycloak({ clientId, clientSecret, issuer: process.env.KEYCLOAK_ISSUER }),
      { providerId: 'zitadel', name: 'Company SSO', clientId, clientSecret, discoveryUrl },
    ],
  }),
],
// admin.login
enableSocial: ['github', 'keycloak', 'zitadel'],

Account creation warning: A social button on the public admin login lets anyone with that provider account create a (non-admin) user row — the role gate blocks admin access, but the row (and any creation hooks) is still created. Set Better Auth's disableImplicitSignUp (per-provider or global) if you don't want open sign-up. This is why enableSocial defaults to false.

Only create users who pass the role gate

When your own identity provider is the source of truth and only some of its users should reach the admin, a role-less user who clicks "Continue with …" still gets a user row, even though the role gate then shows Access Denied. The plugin can't prevent that: Better Auth creates the row during the OAuth callback, before the admin checks roles. Refuse it in Better Auth instead. If a databaseHooks.user.create.before hook returns false, the user (and the linked account) is never written, and the login page shows the returned unable_to_create_user error.

tsconst ADMIN_ROLES = ['admin', 'editor']

betterAuth({
  // Your provider's mapProfileToUser must return the roles, and `role` must be a
  // declared user field, so they are already on `user` when the hook runs.
  databaseHooks: {
    user: {
      create: {
        before: async (user, ctx) => {
          // Only gate OAuth sign-ups; leave admin-created users and other flows alone.
          if (!ctx?.path?.includes('/callback/')) return
          const roles = [user.role].flat().filter(Boolean)
          if (!roles.some((r) => ADMIN_ROLES.includes(r))) return false
        },
      },
    },
  },
})
  • Creation only. The hook never runs for users who already exist. Someone who later loses their role keeps their row (and any content they are linked to as author or editor); the role gate blocks their admin access. Use overrideUserInfo: true on the provider so their roles refresh on each sign-in.
  • Better Auth logs an error ("Unable to create OAuth user") each time a user is refused. That is expected, not a fault.
  • If the whole provider is admin-only and you provision users another way, disableImplicitSignUp: true on the provider is simpler: no user is ever created from a sign-in.
  • With firstUserAdmin on, the plugin promotes the first user after this hook runs, so a first user without a role from the provider is refused here. Seed that first admin through the provider, or create them another way first.

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