# Using with AI agents (/docs/ai-agents) Every page on this site is published as plain Markdown alongside its HTML, so a coding agent can read the documentation directly instead of scraping rendered pages. ## Endpoints [#endpoints] | URL | Contents | | ----------------- | ------------------------------------------------------ | | `/llms.txt` | Index of every page with titles, descriptions and URLs | | `/llms-full.txt` | The entire documentation as one Markdown document | | `/docs/.md` | A single page as Markdown | Start an agent with `/llms.txt` so it can see the structure, then let it fetch the specific pages it needs. `/llms-full.txt` is the one-shot option when you would rather paste everything into a single context window. ```bash curl https://payload-auth.dev/llms.txt curl https://payload-auth.dev/docs/reference/plugin-options.md ``` ## Content negotiation [#content-negotiation] Requesting a docs page with a Markdown `Accept` header returns Markdown rather than HTML, so an agent's default fetch tool gets clean content without any special URL handling: ```bash curl -H "Accept: text/markdown" https://payload-auth.dev/docs/getting-started/installation ``` Appending `.md` to any docs URL does the same thing. ## Copy from the page [#copy-from-the-page] Every page has a **Copy Markdown** button and a view-options menu in its header, for grabbing the source of the page you are reading and pasting it into a chat. ## Wiring it into your editor [#wiring-it-into-your-editor] Add the documentation to your project's `CLAUDE.md` or `AGENTS.md`: ```md title="AGENTS.md" ## Authentication This project uses `payload-auth` — Better Auth running on Payload CMS. Documentation: https://payload-auth.dev/llms.txt Full text: https://payload-auth.dev/llms-full.txt Rules: - Auth collections (`users`, `sessions`, `accounts`, `verifications`) are generated by the plugin. Do not declare them by hand; extend them with `collectionOverrides`. - Never set `database` in `betterAuthOptions` — the plugin supplies the adapter. - `nextCookies()` must be the last entry in the Better Auth `plugins` array. - `user.role` is a string array, not a string. - Re-run `payload generate:importmap` after changing login methods or Better Auth plugins. ``` Add a rule under `.cursor/rules/`: ```md title=".cursor/rules/payload-auth.mdc" --- description: payload-auth conventions globs: ["**/payload.config.ts", "**/lib/auth/**"] --- Auth is handled by `payload-auth` (Better Auth on Payload CMS). Reference: https://payload-auth.dev/llms-full.txt - Auth collections are generated by the plugin; extend via `collectionOverrides`. - `betterAuthOptions` has no `database` key. - Keep `nextCookies()` last in the plugins array. - `user.role` is a string array. ``` Most agents accept a URL in their context or a fetch tool. Point them at: ``` https://payload-auth.dev/llms.txt ``` and let them follow links to individual `.md` pages as needed. ## Facts worth pinning [#facts-worth-pinning] Agents trained before this integration existed tend to make the same handful of mistakes. These are the corrections worth stating explicitly: * **The auth collections are generated.** `users`, `sessions`, `accounts`, `verifications` and `admin-invitations` are injected by the plugin. Writing them by hand duplicates work and usually conflicts. * **`database` is not a valid option.** The exported `BetterAuthOptions` type has it removed; the plugin supplies the Payload adapter. * **`role` is an array.** It is a Payload multi-select, so `['admin']`, not `'admin'`. * **Foreign keys are relationships.** In Payload queries use `user`, not `userId`, on `sessions` and `accounts`. Better Auth code still uses `userId`. * **`nextCookies()` goes last.** * **`admin.loginMethods` is UI only.** It changes which buttons the admin views render; it does not enable or disable providers. * **Import from `payload-auth/better-auth`,** not from `payload-auth/better-auth/plugin`, for ordinary use. * **`getPayloadAuth(configPromise)`** is how you get a typed `payload.betterAuth`. ## Better Auth's own docs [#better-auths-own-docs] `payload-auth` does not change Better Auth's API, so for anything about endpoints, client methods or plugin options, Better Auth's documentation is authoritative: ``` https://www.better-auth.com/llms.txt ``` Give an agent both sources: this site for the Payload integration, Better Auth's for the auth surface itself. # Introduction (/docs) `payload-auth` is a [Payload CMS](https://payloadcms.com) plugin that swaps Payload's built-in authentication for [Better Auth](https://www.better-auth.com). Once installed, the Payload admin panel and your application authenticate against the same Better Auth session. There is no second user table and no bridging code: a user who signs in with Google on your marketing site is the same user Payload sees in the admin panel, with the same session cookie. ## What you get [#what-you-get] `users`, `sessions`, `accounts` and `verifications` are built from the Better Auth schema, plus one collection per Better Auth plugin you enable. The plugin installs a Payload database adapter. Every Better Auth endpoint, plugin and client method behaves exactly as the Better Auth docs describe. Login, signup, forgot password, reset password and two-factor verification are rendered by Better Auth-powered views inside the Payload admin panel. Define roles once. The plugin wires them into Payload access control and into the Better Auth `admin` plugin. ## A complete example [#a-complete-example] ```ts title="src/payload.config.ts" import { buildConfig } from 'payload' import { betterAuthPlugin } from 'payload-auth/better-auth' export default buildConfig({ admin: { user: 'users' }, // ... db, editor, secret plugins: [ betterAuthPlugin({ users: { roles: ['user', 'admin'], adminRoles: ['admin'], }, betterAuthOptions: { emailAndPassword: { enabled: true }, socialProviders: { google: { clientId: process.env.GOOGLE_CLIENT_ID!, clientSecret: process.env.GOOGLE_CLIENT_SECRET!, }, }, }, }), ], }) ``` That single plugin call generates the auth collections, replaces the admin login views, and exposes a fully configured Better Auth instance at `payload.betterAuth`. ## Requirements [#requirements] | Requirement | Version | | ------------------------------------ | -------------- | | `payload` | `>=3.79.1 <4` | | `@payloadcms/next`, `@payloadcms/ui` | `>=3.79.1 <4` | | `better-auth`, `@better-auth/core` | `>=1.5.0 <2` | | `next` | `>=15.4.8 <17` | | `react`, `react-dom` | `>=19.2.1 <20` | | `zod` | `^4.3.6` | Any Payload database adapter works — the plugin talks to Payload's Local API, not to your database directly. ## Where to go next [#where-to-go-next] Install the package and wire it into your Payload config. Bootstrap the first admin user and get into the panel. Every option `betterAuthPlugin()` accepts. Point Cursor, Claude Code or Copilot at these docs. # Troubleshooting (/docs/troubleshooting) ## The admin panel loops on `buildFormState` requests [#the-admin-panel-loops-on-buildformstate-requests] **Symptom.** After signing in, the admin panel fires `buildFormState` POSTs endlessly and never settles. **Cause.** Something is emitting `Set-Cookie` during a Server Action. With `nextCookies()` enabled, that cookie is written through `cookies().set()`, which invalidates the Next.js router cache, forces a re-render, and starts the cycle again. The plugin's auth strategy is not the culprit — it calls `getSession` with `disableRefresh: true` precisely to avoid this. If you still see the loop, another endpoint in your app is refreshing the session during a Server Action. Look for `getSession` calls without `disableRefresh`, or custom middleware that touches auth cookies. See [issue #139](https://github.com/payload-auth/payload-auth/issues/139). ## `BetterAuth plugin not initialized` [#betterauth-plugin-not-initialized] `getPayloadAuth` throws this when `payload.betterAuth` is missing. Either: * `betterAuthPlugin()` is not in your Payload config's `plugins` array, or * `disabled: true` is set, or * you imported a different Payload config than the one the plugin is registered on. ## `Collection does not exist` [#collection-model-does-not-exist] The adapter resolved a Better Auth model to a Payload slug that is not registered. Using the plugin, this usually means you renamed a collection somewhere the rename did not propagate — check that `users.slug` matches `admin.user` in your Payload config. Using the adapter standalone, it means a `modelName` is missing or wrong; see [Adapter](/docs/reference/adapter). ## Admin components fail to load [#admin-components-fail-to-load] Symptoms range from a blank login view to import errors mentioning `payload-auth/better-auth/plugin/client` or `#RSCRedirect`. The import map is stale. Regenerate it: ```bash pnpm payload generate:importmap ``` Run this after installing the plugin and after any change to `admin.loginMethods`, `socialProviders`, or which Better Auth plugins are enabled. ## Social login redirects to the wrong URL [#social-login-redirects-to-the-wrong-url] Better Auth builds callback URLs from `baseURL`. If `NEXT_PUBLIC_BETTER_AUTH_URL` does not match the origin the browser sees — http vs https, with or without `www`, a proxy in front — the callback fails. Check that `baseURL` and `trustedOrigins` both match the real origin, and that the provider's registered redirect URI is exactly `/api/auth/callback/`. ## Signed in on the frontend but blocked from `/admin` [#signed-in-on-the-frontend-but-blocked-from-admin] The session is valid; the roles are not. Admin access requires one of the user's roles to appear in `users.adminRoles`. Open the user in the admin panel — or query it — and confirm the `role` array contains an admin role. Remember `role` is a multi-select: `['admin']`, not `'admin'`. ## Sign-in succeeds but the session is empty [#sign-in-succeeds-but-the-session-is-empty] Usually the cookie cache. If you marked large fields `saveToJWT: true`, the session cookie can exceed the browser's \~4 KB limit and be dropped silently. Reduce the cached field set, or turn `session.cookieCache` off to confirm the diagnosis. See [Sessions and cookies](/docs/concepts/sessions). ## Cannot sign in after enabling email verification [#cannot-sign-in-after-enabling-email-verification] With `requireEmailVerification: true`, sign-in is blocked until the address is verified. In development there is usually no email transport, so log the URL instead of sending it and open it manually: ```ts emailVerification: { sendOnSignUp: true, async sendVerificationEmail({ user, url }) { console.log('Verify email for', user.email, url) }, }, ``` ## The invite email is never sent [#the-invite-email-is-never-sent] `POST /api/users/send-invite` returns 500 and logs that the send function is missing when `adminInvitations.sendInviteEmail` is not configured. Generating a link and sharing it manually still works. See [Admin invitations](/docs/guides/admin-invitations). ## A user is locked out of 2FA [#a-user-is-locked-out-of-2fa] If they lost both their authenticator and their backup codes, delete their `twoFactors` record and uncheck `twoFactorEnabled` on their user document in the admin panel. ## Missing tables after enabling a plugin [#missing-tables-after-enabling-a-plugin] Enabling a Better Auth plugin adds collections, which means new tables. Create and run a migration: ```bash pnpm payload migrate:create pnpm payload migrate ``` ## Type errors on `betterAuthOptions` [#type-errors-on-betterauthoptions] **`Object literal may only specify known properties … 'database'`** — the plugin supplies the database adapter, so its `BetterAuthOptions` type has `database` removed. Delete it. **`api` or `$Infer` typed too loosely** — pass your options type as the generic: `getPayloadAuth(configPromise)`, where `PayloadAuthConfig` is `typeof payloadAuthOptions` declared with `satisfies` rather than a type annotation. ## Turning on debug logging [#turning-on-debug-logging] ```ts betterAuthPlugin({ debug: { enableDebugLogs: true, // every adapter call, in and out logTables: true, // tables Better Auth needs, on init }, }) ``` Both are verbose. Leave them off in production. ## Still stuck [#still-stuck] Open an issue at [github.com/payload-auth/payload-auth/issues](https://github.com/payload-auth/payload-auth/issues) with your `payload-auth`, `payload` and `better-auth` versions, your database adapter, and the relevant part of your plugin options with secrets removed. # Admin panel integration (/docs/concepts/admin-panel) The plugin rewrites `config.admin` so the Payload admin panel authenticates through Better Auth rather than Payload's own login. ## Replaced views [#replaced-views] | Route | Component | Condition | | -------------------------- | ----------------- | --------------------------- | | `/admin/login` | `AdminLogin` | always | | `/admin/signup` | `AdminSignup` | always (requires `?token=`) | | `/admin/forgot-password` | `ForgotPassword` | always | | `/admin/reset-password` | `ResetPassword` | always | | `/admin/two-factor-verify` | `TwoFactorVerify` | `twoFactor` plugin enabled | `admin.routes.login` is set to `/login-redirect`. A `RSCRedirect` component is prepended to `admin.components.afterLogin` and forwards to `/admin/login`, which keeps Payload's own login screen out of the flow. The logout button is replaced with one that ends the Better Auth session. Because these are React components resolved through Payload's import map, run `payload generate:importmap` after installing the plugin and after changing which login methods or Better Auth plugins are enabled. ## Login methods shown [#login-methods-shown] The login and signup views render buttons for the methods in `admin.loginMethods`. If you do not set it, the plugin infers the list: * every key of `betterAuthOptions.socialProviders` * `emailPassword`, when `emailAndPassword.enabled` is true * `passkey`, when the `passkey` plugin is enabled Override it to show a different set — for example, an admin panel that only accepts passkeys even though the public app also allows email and password: ```ts betterAuthPlugin({ admin: { loginMethods: ['passkey'] }, }) ``` Valid values are `emailPassword`, `magicLink`, `emailOTP`, `phonePassword`, `phoneOTP`, `phoneMagicLink`, `passkey`, and the social providers: `apple`, `discord`, `facebook`, `github`, `google`, `linkedin`, `microsoft`, `spotify`, `tiktok`, `twitter`, `twitch`, `zoom`, `gitlab`, `roblox`, `vk`, `kick`, `reddit`. `admin.loginMethods` changes which buttons the admin views render. It does not enable or disable providers — that is `betterAuthOptions`. Your frontend is unaffected. ## Username login [#username-login] With the Better Auth `username` plugin enabled, the login views also accept a username. Which identifiers appear is driven by Payload's `auth.loginWithUsername` on your users collection: * `loginWithUsername: true` → email and username * `loginWithUsername: { allowEmailLogin: false }` → username only ## Secrets and the admin HTML [#secrets-and-the-admin-html] The views receive your plugin options as server props, and Payload serialises server props into the RSC payload of the admin HTML — meaning anything passed there is visible to anyone who loads the page. The plugin strips `secret` and every `socialProviders[*].clientSecret` before handing options to the views. This only covers what the plugin passes. If you put credentials somewhere else in your plugin options, they are not stripped. Keep secrets in environment variables. ## User management buttons [#user-management-buttons] The users collection gains two pieces of UI: * **Invite button**, in the collection's description area. Generates an admin invitation link — see [Admin invitations](/docs/guides/admin-invitations). * **Admin actions tab**, on the user edit view: *Impersonate*, *Revoke All Sessions*, and *Ban* / *Unban*. This tab only appears when the Better Auth `admin` plugin is enabled, since it drives Better Auth's admin API. Impersonating redirects to `/` acting as the target user. The ban state is stored in the `banned` and `banReason` fields on the user document, and a banned user is rejected by the auth strategy, so they cannot reach the panel. ## Two-factor challenge [#two-factor-challenge] When the `twoFactor` plugin is enabled, a sign-in that requires a second factor redirects to `/admin/two-factor-verify`, which accepts a TOTP code or a backup code. Configure the client redirect target too: ```ts twoFactorClient({ onTwoFactorRedirect() { window.location.href = '/two-factor' }, }) ``` ## Reusing the plugin's components [#reusing-the-plugins-components] The admin components are exported for use in your own screens: ```tsx import { AdminInviteButton, LogoutButton, TwoFactorAuth } from 'payload-auth/better-auth/plugin/client' ``` ```tsx import { AdminLogin, Passkeys } from 'payload-auth/better-auth/plugin/rsc' ``` See [Exports](/docs/reference/exports) for the full list. # Collections (/docs/concepts/collections) The plugin generates Payload collections from the Better Auth schema. You do not declare them in `buildConfig` — they are injected during config transformation, merged with any collection you already defined under the same slug. ## Base collections [#base-collections] These four are always created: | Slug | Better Auth model | Contents | | --------------- | ----------------- | --------------------------------------------------------------- | | `users` | `user` | email, name, image, `emailVerified`, `role`, ban state | | `sessions` | `session` | token, `expiresAt`, IP address, user agent, `user` relationship | | `accounts` | `account` | provider ID, account ID, OAuth tokens, `user` relationship | | `verifications` | `verification` | identifier, value, `expiresAt` | Plus one that has no Better Auth equivalent: | Slug | Contents | | ------------------- | --------------------------------------- | | `admin-invitations` | role, token, generated URL, `expiresAt` | ## Plugin collections [#plugin-collections] Enabling a Better Auth plugin that needs storage adds its collections automatically: | Better Auth plugin | Collections | | ----------------------------------- | -------------------------------------------------------------- | | `twoFactor` | `twoFactors` | | `passkey` | `passkeys` | | `apiKey` | `apiKeys` | | `organization` | `organizations`, `members`, `invitations`, `organizationRoles` | | `organization` with `teams.enabled` | `teams`, `teamMembers` | | `sso` | `ssoProviders` | | `oidc` | `oauthApplications`, `oauthAccessTokens`, `oauthConsents` | | `deviceAuthorization` | `deviceCode` | | `stripe` | `subscriptions` | | `scim` | `scimProvider` | | `jwt` | `jwks` | | database rate limiting | `rateLimit` | ## Field mapping [#field-mapping] Better Auth field keys are not always the Payload field names. Foreign keys in particular become real Payload relationships: | Model | Better Auth key | Payload field | | ------------------------------------- | --------------------------------------- | --------------------------------- | | `session` | `userId` | `user` | | `account` | `userId` | `user` | | `twoFactor`, `passkey`, `ssoProvider` | `userId` | `user` | | `member` | `organizationId`, `userId`, `teamId` | `organization`, `user`, `team` | | `invitation` | `organizationId`, `inviterId`, `teamId` | `organization`, `inviter`, `team` | | `team` | `organizationId` | `organization` | The plugin rewrites Better Auth's `fields` config to match, so `session.userId` in Better Auth code resolves to the `user` relationship in Payload. Scalar types map as expected: `string` → `text`, `number` → `number`, `boolean` → `checkbox`, `date` → `date`. ## Renaming a collection [#renaming-a-collection] Set `slug`. This changes both the Payload slug and the Better Auth `modelName`, so the two stay in sync: ```ts betterAuthPlugin({ users: { slug: 'members' }, sessions: { slug: 'user-sessions' }, }) ``` If you rename `users`, update `admin.user` in your Payload config to match. ## Hiding collections [#hiding-collections] Hide individual collections from the admin sidebar: ```ts betterAuthPlugin({ sessions: { hidden: true }, verifications: { hidden: true }, }) ``` Or hide every plugin-generated collection (passkeys, two-factors, api-keys, and so on) in one go: ```ts betterAuthPlugin({ hidePluginCollections: true }) ``` Base collections are grouped under **Auth** in the sidebar. Change that with `collectionAdminGroup`. ## Extending a collection [#extending-a-collection] Every collection accepts a `collectionOverrides` function that receives the built config and returns a modified one. Use it to add fields, change access control, attach hooks, or adjust admin metadata: ```ts betterAuthPlugin({ users: { collectionOverrides: ({ collection }) => ({ ...collection, fields: [ ...collection.fields, { name: 'bio', type: 'textarea', saveToJWT: false, }, ], admin: { ...collection.admin, defaultColumns: ['email', 'name', 'role'], }, }), }, }) ``` For plugin collections, use `pluginCollectionOverrides`, keyed by collection: ```ts betterAuthPlugin({ pluginCollectionOverrides: { organizations: ({ collection }) => ({ ...collection, admin: { ...collection.admin, group: 'Tenancy' }, }), }, }) ``` Adding fields is safe. Removing or renaming a field that Better Auth writes to will break the adapter at runtime, because the schema mapping still expects it. ## Adding custom fields Better Auth can read [#adding-custom-fields-better-auth-can-read] To let Better Auth read and write a field you added, declare it in Better Auth's `user.additionalFields` as well: ```ts export const betterAuthOptions = { user: { additionalFields: { bio: { type: 'string', required: false }, }, }, } satisfies BetterAuthOptions ``` Use `inferAdditionalFields()` on the client so the field is typed there too. ## Defining a collection yourself [#defining-a-collection-yourself] If you declare a collection with a slug the plugin also generates, the plugin merges yours as the base and layers its own fields, hooks, endpoints and access control on top. This is useful for keeping your custom fields in a normal collection file, but `collectionOverrides` gives you the last word and is usually clearer. # How it works (/docs/concepts/how-it-works) `payload-auth` is deliberately thin. It never forks or patches Better Auth. Instead it does three things: 1. Gives Better Auth a **database adapter** that speaks Payload's Local API. 2. **Generates Payload collections** from the Better Auth schema so those records are editable in the admin panel. 3. Registers a **Payload auth strategy** so the admin panel trusts Better Auth sessions. ``` Better Auth client │ HTTP + cookies ▼ Better Auth core ← never modified │ DBAdapter interface ▼ Payload adapter ← payload-auth │ Local API, depth: 0 ▼ Payload CMS ──▶ your database ``` Because Better Auth core is untouched, every documented Better Auth endpoint, plugin and client method behaves exactly as its own docs describe. ## Initialisation [#initialisation] `betterAuthPlugin(options)` returns a Payload config transformer. When Payload builds its config, the plugin: 1. Applies `setLoginMethods` — infers which login buttons the admin views should show from your enabled providers and plugins, unless you set `admin.loginMethods` explicitly. 2. Derives the default Better Auth schema for the models your plugins require. 3. Builds Payload collections from that schema (**pass 1**). 4. Reconciles the schema with the collections that were actually produced — picking up any slug you overrode. 5. Builds the collections again with the resolved schema (**pass 2**). 6. Runs `sanitizeBetterAuthOptions`, which rewrites `modelName` and field mappings so Better Auth addresses your real Payload slugs and field names. 7. Replaces the admin auth views and injects the collections into the config. 8. Registers an `onInit` hook. Hooks and endpoints need the *final* slugs of collections other than their own. The user collection's `beforeDelete` hook, for example, cascades into sessions, accounts and passkeys. Pass 1 establishes the slugs; pass 2 rebuilds with correct cross-references. When Payload boots, `onInit` calls `betterAuth()` with the sanitized options and the Payload adapter, then attaches the result to `payload.betterAuth` as a non-writable property. ## The adapter [#the-adapter] The adapter implements Better Auth's `DBAdapter` interface. Every operation follows the same shape: resolve the Payload client, map the Better Auth model name to a collection slug, translate the query, call the Local API, translate the result back. | Better Auth method | Payload API | | ----------------------- | ---------------------------------------------------- | | `create` | `payload.create()` | | `findOne` | `payload.findByID()` or `payload.find({ limit: 1 })` | | `findMany` | `payload.find()` | | `update` / `updateMany` | `payload.update()` | | `delete` / `deleteMany` | `payload.delete()` | | `count` | `payload.count()` | A `where` clause that is just `id equals X` is detected and routed to `findByID`, which is the faster path. ### Translation [#translation] Because Payload and Better Auth disagree on names, types and shapes, a transform layer sits between them: * **Field names.** Better Auth's `userId` becomes Payload's `user` relationship field on `sessions` and `accounts`; the reverse mapping is applied on the way out. * **IDs.** Better Auth expects strings. Payload may use numeric IDs (Postgres) or text IDs (Mongo). The adapter stringifies on output and converts back on input, driven by `payload.db.defaultIDType`. * **Operators.** `eq` → `equals`, `ne` → `not_equals`, `gt` → `greater_than`, `starts_with` / `ends_with` → `like`, and so on. * **Dates.** Payload returns ISO strings; Better Auth wants `Date` objects. * **Depth.** Every query runs at `depth: 0`, so relationships come back as raw IDs rather than populated documents. This keeps responses in the shape Better Auth expects and keeps session cookies small. ## The Payload auth strategy [#the-payload-auth-strategy] The generated `users` collection declares a custom auth strategy. On each authenticated admin request it calls `betterAuth.api.getSession({ headers })` with `query: { disableRefresh: true }`, then loads the full user document from Payload. `disableRefresh` matters. The strategy runs on every admin request; if it were allowed to refresh the session, each refresh would emit a `Set-Cookie`. With `nextCookies()` in your plugin list that cookie is written via `cookies().set()` inside a Server Action, which invalidates the Next.js router cache, triggers a re-render, and calls the strategy again — an infinite `buildFormState` loop. Sessions still refresh normally on real Better Auth endpoints and on the plugin's `/refresh-token` endpoint. Banned or otherwise locked users resolve to `null`, so they cannot reach the panel. ## Layers at a glance [#layers-at-a-glance] | Layer | Responsibility | | ------------- | ------------------------------------------------------------------ | | Configuration | `PayloadAuthOptions` → collections + sanitized `BetterAuthOptions` | | Plugin | Config transformation, `onInit`, auth strategy | | Adapter | Translate `DBAdapter` calls to the Payload Local API | | Admin UI | Login, signup, password reset and 2FA views | # Roles and access control (/docs/concepts/roles-and-access) ## Defining roles [#defining-roles] Roles live in the `users` block of your plugin options: ```ts betterAuthPlugin({ users: { roles: ['user', 'editor', 'admin'], adminRoles: ['admin'], defaultRole: 'user', defaultAdminRole: 'admin', }, }) ``` | Option | Default | Meaning | | ------------------ | ----------- | ------------------------------------------------- | | `roles` | `['user']` | Every selectable role on the users collection | | `adminRoles` | `['admin']` | Roles granted admin access to auth collections | | `defaultRole` | `'user'` | Assigned to new users | | `defaultAdminRole` | `'admin'` | Assigned to the first admin and to invited admins | `roles` and `adminRoles` are merged and deduplicated, so a role listed in `adminRoles` does not also need to appear in `roles`. The `role` field on `users` is a Payload `select` with `hasMany: true`, so a user's role is a **string array** — `['admin']`, or `['editor', 'admin']` for someone holding both. ## Who gets into the admin panel [#who-gets-into-the-admin-panel] The users collection's `admin` access returns true when any of the user's roles appears in `adminRoles`. Everyone else is refused at the panel door, even with a valid session. Role checks tolerate both shapes: an array (`['admin']`) and a comma-separated string (`'editor,admin'`), which is what turns up when a role arrives through a JWT claim. ## Access control on generated collections [#access-control-on-generated-collections] **`users`** | Operation | Rule | | --------- | --------------------------------------------------------------------------- | | `read` | Admin roles read everything; other users read only their own document | | `create` | Admin roles only | | `update` | Admin roles, or a user updating their own document with only allowed fields | | `delete` | Admin roles, or a user deleting their own document | | `admin` | Admin roles only | **All other auth collections** (`sessions`, `accounts`, `verifications`, `admin-invitations`, and the plugin collections) are admin-roles-only for every operation. These rules apply to Payload's Local and REST APIs and to the admin panel. Requests to `/api/auth/*` go through Better Auth, which enforces its own authorization — the adapter runs underneath it and is not subject to Payload access control. ## Self-service updates [#self-service-updates] Non-admin users may only write the fields you list in `users.allowedFields`: ```ts betterAuthPlugin({ users: { allowedFields: ['name', 'image', 'bio'], }, }) ``` Default is `['name']`. An update containing any field outside that list is denied. Password changes are handled separately and do not need to be listed. To change a password, send **both** `currentPassword` and `password`; the plugin verifies the current password before allowing the write. Sending only one of the two is denied. ## Better Auth `admin` plugin [#better-auth-admin-plugin] If you use Better Auth's `admin` plugin, the plugin feeds your Payload role config into it, so `defaultRole` and `adminRoles` do not need restating: ```ts import { admin } from 'better-auth/plugins' export const betterAuthOptions = { plugins: [admin()], } satisfies BetterAuthOptions ``` Middleware normalises roles between Payload's array field and the string that Better Auth's admin plugin expects, in both directions, so `admin.hasPermission` and the client's `adminClient()` methods work unchanged. For fine-grained permissions, define an access controller and pass it through as usual: ```ts import { createAccessControl } from 'better-auth/plugins' import { defaultStatements, defaultRoles } from 'better-auth/plugins/admin/access' const ac = createAccessControl({ ...defaultStatements, project: ['create', 'share', 'update', 'delete'], }) const superAdmin = ac.newRole({ ...ac.statements }) export const betterAuthOptions = { plugins: [ admin({ ac, roles: { ...defaultRoles, superAdmin }, defaultRole: 'user' }), ], } satisfies BetterAuthOptions ``` Any role you use there should also appear in `users.roles` so it is selectable in the Payload admin UI. ## Access control in your own collections [#access-control-in-your-own-collections] Read roles off `req.user` like any other Payload field: ```ts import type { Access } from 'payload' export const isEditor: Access = ({ req: { user } }) => { const roles = Array.isArray(user?.role) ? user.role : [] return roles.includes('editor') || roles.includes('admin') } ``` # Sessions and cookies (/docs/concepts/sessions) Sessions are Better Auth sessions. Payload does not issue its own — the `users` collection uses a custom auth strategy that resolves the Better Auth session on every request. ## The flow [#the-flow] 1. The client hits a Better Auth endpoint (`/api/auth/sign-in/email`, for example). 2. Better Auth verifies credentials, writes a `sessions` document through the adapter, and returns a `Set-Cookie`. 3. Later requests carry that cookie. Better Auth resolves it to a session; Payload's strategy loads the matching user document. Because both sides read the same cookie, signing in on your frontend also signs you into the admin panel, subject to your `adminRoles`. ## Cookie cache [#cookie-cache] Better Auth can store a signed copy of the session and user in the cookie itself, avoiding a database round trip on every request: ```ts export const betterAuthOptions = { session: { cookieCache: { enabled: true, maxAge: 5 * 60, // seconds }, }, } satisfies BetterAuthOptions ``` What lands in that cookie is controlled by Payload's `saveToJWT` on your collection fields. The plugin mirrors `saveToJWT: false` onto Better Auth's returned-field set, so a field you exclude in Payload is never serialised into the cookie either. By default the plugin marks `name`, `emailVerified` and `role` as `saveToJWT: true`, and `image`, `createdAt`, `updatedAt` and the `accounts` / `sessions` join fields as `false`. Adjust with `collectionOverrides`: ```ts betterAuthPlugin({ users: { collectionOverrides: ({ collection }) => ({ ...collection, fields: collection.fields.map((field) => 'name' in field && field.name === 'image' ? { ...field, saveToJWT: true } : field, ), }), }, }) ``` Browsers cap cookies at roughly 4 KB. Marking large fields `saveToJWT: true` can push the session cookie over that limit and break sign-in. Keep the cached set small. ## Session refresh in the admin panel [#session-refresh-in-the-admin-panel] Payload's auth strategy calls `getSession` with `disableRefresh: true`, so it reads the session without extending it and never emits a `Set-Cookie`. This is deliberate — see [How it works](/docs/concepts/how-it-works) for why refreshing there causes an infinite render loop when `nextCookies()` is enabled. Sessions still refresh normally when the client calls Better Auth endpoints directly, and through the plugin's `POST /api/users/refresh-token` endpoint. ## Cascade deletes [#cascade-deletes] Deleting a user through Payload runs a `beforeDelete` hook that removes the related `sessions`, `accounts`, `passkeys` and `twoFactors` records. Payload does not enforce referential integrity, and Better Auth assumes it, so the hook closes that gap. Deleting a user through Better Auth's own API goes through the adapter and therefore triggers the same hook. ## Multi-session and impersonation [#multi-session-and-impersonation] The `multiSession` and `admin` plugins work as documented — sessions are just rows in the `sessions` collection. Impersonation writes `impersonatedBy` onto the session record, which you can see in the admin panel. ```ts import { admin, multiSession } from 'better-auth/plugins' export const betterAuthOptions = { plugins: [admin(), multiSession()], } satisfies BetterAuthOptions ``` ## Reading a session [#reading-a-session] ```ts import { headers } from 'next/headers' import { getPayload } from '@/lib/payload' const payload = await getPayload() const session = await payload.betterAuth.api.getSession({ headers: await headers(), }) ``` ```tsx 'use client' import { authClient } from '@/lib/auth/client' const { data: session, isPending } = authClient.useSession() ``` ```ts import { headers } from 'next/headers' import { getPayload } from '@/lib/payload' const payload = await getPayload() const { user } = await payload.auth({ headers: await headers() }) ``` `payload.auth()` runs the plugin's auth strategy and returns the full Payload user document. # Client setup (/docs/getting-started/client-setup) `payload-auth` does not ship its own client. You use the standard Better Auth client, which talks to the route handler you mounted at `/api/auth/[...all]`. ## Create the client [#create-the-client] ```ts title="src/lib/auth/client.ts" import { createAuthClient } from 'better-auth/react' import { adminClient, inferAdditionalFields, twoFactorClient, } from 'better-auth/client/plugins' import { betterAuthOptions } from './options' export const authClient = createAuthClient({ baseURL: process.env.NEXT_PUBLIC_BETTER_AUTH_URL, plugins: [ adminClient(), twoFactorClient(), inferAdditionalFields({ user: { role: { type: 'string' } }, }), ], $InferAuth: betterAuthOptions, }) export const { signIn, signUp, signOut, useSession } = authClient ``` Two rules to keep in mind: * **Client plugins mirror server plugins.** Every Better Auth plugin that adds client methods needs its matching `*Client()` entry here. * **Pass `$InferAuth`.** Handing your `betterAuthOptions` to the client gives you typed sessions, users and plugin methods without importing server code into the browser. ## Use it [#use-it] ```tsx title="src/components/sign-in.tsx" 'use client' import { authClient } from '@/lib/auth/client' export function SignIn() { const { data: session, isPending } = authClient.useSession() if (isPending) return null if (session) { return ( ) } return ( ) } ``` Everything else — `signIn.email`, `signUp.email`, `organization.*`, `passkey.*` — is plain Better Auth. See the [Better Auth client docs](https://www.better-auth.com/docs/concepts/client). ## The `role` field [#the-role-field] `payload-auth` stores `role` as a Payload multi-select field, so on the session object it is a **string array**, not a string: ```ts const { data: session } = authClient.useSession() session?.user.role // e.g. ['admin'] ``` The exported `BetterAuthReturn['$Infer']['Session']` type reflects this. If you use the Better Auth `admin` plugin, its role checks still work — the plugin's middleware normalises between the two shapes. ## Server-side access [#server-side-access] On the server, skip the HTTP client and call the auth instance directly: ```ts import { headers } from 'next/headers' import { getPayload } from '@/lib/payload' const payload = await getPayload() const session = await payload.betterAuth.api.getSession({ headers: await headers() }) ``` See [Server-side usage](/docs/guides/server-side) for the full picture. # Create your first admin (/docs/getting-started/first-admin) Payload's usual "create first user" screen is replaced by an invitation-based signup flow. On an empty database the plugin bootstraps that invitation for you, so there is nothing to configure. ## What happens on a fresh database [#what-happens-on-a-fresh-database] 1. You open `/admin`. You are not signed in, so Payload redirects to its login route — which the plugin has repointed to `/admin/login-redirect`, then on to `/admin/login`. 2. The login view counts users whose role equals `users.defaultAdminRole` (default `"admin"`). 3. If that count is zero, the view creates an `admin-invitations` record with a random token and redirects you to `/admin/signup?token=`. 4. You fill in the signup form. The token is validated against `admin-invitations`, and the new user is created with the admin role. If an unused admin invitation already exists, the view reuses its token rather than creating another one. Once one user holds the admin role, `/admin/login` stops bootstrapping and renders the normal login form. Additional admins must be invited — see [Admin invitations](/docs/guides/admin-invitations). ## Verifying your email in development [#verifying-your-email-in-development] If you set `emailVerification.sendOnSignUp: true` (as the [installation guide](/docs/getting-started/installation) does), you cannot sign in until the address is verified. In development you typically have no real email transport, so log the URL instead: ```ts title="src/lib/auth/options.ts" emailVerification: { sendOnSignUp: true, autoSignInAfterVerification: true, async sendVerificationEmail({ user, url }) { console.log('Verify email for', user.email, url) }, }, ``` After signing up, copy the logged URL and open it: ``` http://localhost:3000/api/auth/verify-email?token=…&callbackURL=/admin ``` You are verified, signed in, and redirected to the admin panel. To skip this loop entirely while developing, set `requireEmailVerification: false` and `sendOnSignUp: false`. ## Admin routes added by the plugin [#admin-routes-added-by-the-plugin] The plugin sets `admin.routes.login` to `/login-redirect` and registers these views underneath your admin route: | Route | View | Notes | | -------------------------- | ----------------- | ------------------------------------------- | | `/admin/login` | Better Auth login | Bootstraps the first admin when none exists | | `/admin/signup` | Invitation signup | Requires a valid `?token=` | | `/admin/forgot-password` | Forgot password | | | `/admin/reset-password` | Reset password | | | `/admin/two-factor-verify` | 2FA challenge | Only when the `twoFactor` plugin is enabled | | `/admin/login-redirect` | Internal redirect | Payload's configured login route | ## Creating an admin without the UI [#creating-an-admin-without-the-ui] You can promote any user from a script or seed file using Payload's Local API: ```ts import { getPayload } from '@/lib/payload' const payload = await getPayload() await payload.update({ collection: 'users', where: { email: { equals: 'me@example.com' } }, data: { role: ['admin'] }, }) ``` `role` is a multi-select field (`hasMany: true`), so it takes an array. # Installation (/docs/getting-started/installation) This guide assumes an existing Payload 3 project running inside Next.js — the layout `payload create-payload-app` produces, with `src/payload.config.ts` and the `(payload)` route group. ## 1. Install packages [#1-install-packages] npm pnpm yarn bun ```bash npm install payload-auth better-auth ``` ```bash pnpm add payload-auth better-auth ``` ```bash yarn add payload-auth better-auth ``` ```bash bun add payload-auth better-auth ``` `better-auth` is a peer dependency, so you install it yourself and control its version. `@better-auth/passkey` is also currently declared as a required peer, so install it even if you do not plan to use passkeys: npm pnpm yarn bun ```bash npm install @better-auth/passkey ``` ```bash pnpm add @better-auth/passkey ``` ```bash yarn add @better-auth/passkey ``` ```bash bun add @better-auth/passkey ``` Add the remaining Better Auth packages only if you use them: npm pnpm yarn bun ```bash npm install @better-auth/api-key @better-auth/sso @better-auth/stripe @better-auth/scim ``` ```bash pnpm add @better-auth/api-key @better-auth/sso @better-auth/stripe @better-auth/scim ``` ```bash yarn add @better-auth/api-key @better-auth/sso @better-auth/stripe @better-auth/scim ``` ```bash bun add @better-auth/api-key @better-auth/sso @better-auth/stripe @better-auth/scim ``` ## 2. Set environment variables [#2-set-environment-variables] ```dotenv title=".env" DATABASE_URI=postgres://... PAYLOAD_SECRET=your-payload-secret BETTER_AUTH_SECRET=your-better-auth-secret NEXT_PUBLIC_BETTER_AUTH_URL=http://localhost:3000 ``` `NEXT_PUBLIC_BETTER_AUTH_URL` is the public base URL of your app. Better Auth uses it to build callback URLs, verification links and cookie domains, so it must match the origin the browser actually sees. ## 3. Define your Better Auth options [#3-define-your-better-auth-options] Keep Better Auth options in their own module. You will import them from your Payload config, from `generateSchema`, and (for type inference) from your client. ```ts title="src/lib/auth/options.ts" import type { BetterAuthOptions, PayloadAuthOptions } from 'payload-auth/better-auth' import { admin, twoFactor } from 'better-auth/plugins' import { nextCookies } from 'better-auth/next-js' export const betterAuthOptions = { appName: 'my-app', baseURL: process.env.NEXT_PUBLIC_BETTER_AUTH_URL, trustedOrigins: [process.env.NEXT_PUBLIC_BETTER_AUTH_URL!], emailAndPassword: { enabled: true, requireEmailVerification: true, async sendResetPassword({ user, url }) { console.log('Reset password for', user.email, url) }, }, emailVerification: { sendOnSignUp: true, autoSignInAfterVerification: true, async sendVerificationEmail({ user, url }) { console.log('Verify email for', user.email, url) }, }, socialProviders: { google: { clientId: process.env.GOOGLE_CLIENT_ID as string, clientSecret: process.env.GOOGLE_CLIENT_SECRET as string, }, }, plugins: [admin(), twoFactor(), nextCookies()], } satisfies BetterAuthOptions export const payloadAuthOptions = { users: { slug: 'users', roles: ['user', 'admin'], adminRoles: ['admin'], defaultRole: 'user', allowedFields: ['name'], }, betterAuthOptions, } satisfies PayloadAuthOptions export type PayloadAuthConfig = typeof payloadAuthOptions ``` `payload-auth` supplies the `database` adapter itself. The `BetterAuthOptions` type it exports is Better Auth's options type with `database` removed, so setting it is a type error. Better Auth requires `nextCookies()` to be the final entry in the `plugins` array. ## 4. Add the plugin to your Payload config [#4-add-the-plugin-to-your-payload-config] ```ts title="src/payload.config.ts" import { buildConfig } from 'payload' import { betterAuthPlugin } from 'payload-auth/better-auth' import { payloadAuthOptions } from './lib/auth/options' export default buildConfig({ admin: { user: 'users', importMap: { baseDir: path.resolve(dirname) }, }, collections: [ // your own collections — auth collections are added by the plugin ], db: postgresAdapter({ pool: { connectionString: process.env.DATABASE_URI } }), secret: process.env.PAYLOAD_SECRET!, plugins: [betterAuthPlugin(payloadAuthOptions)], }) ``` `admin.user` must match `users.slug` (default `'users'`). You do not declare the auth collections yourself. The plugin builds `users`, `sessions`, `accounts`, `verifications`, `admin-invitations`, and one collection for each Better Auth plugin that needs storage — see [Collections](/docs/concepts/collections). ## 5. Mount the Better Auth route handler [#5-mount-the-better-auth-route-handler] Better Auth needs its own catch-all route. Reach the auth instance through `getPayloadAuth`, which returns Payload with a typed `betterAuth` property attached. ```ts title="src/lib/payload.ts" import configPromise from '@payload-config' import { getPayloadAuth } from 'payload-auth/better-auth' import type { PayloadAuthConfig } from './auth/options' export const getPayload = async () => getPayloadAuth(configPromise) ``` ```ts title="src/app/api/auth/[...all]/route.ts" import { toNextJsHandler } from 'better-auth/next-js' import { getPayload } from '@/lib/payload' const payload = await getPayload() export const { POST, GET } = toNextJsHandler(payload.betterAuth) ``` This must live at `app/api/auth/[...all]` — outside the `(payload)` route group, which has its own catch-all handler. ## 6. Regenerate the import map [#6-regenerate-the-import-map] The plugin registers React components (login views, logout button, admin buttons) into Payload's admin panel. Payload resolves them through its import map, so regenerate it after installing: ```bash pnpm payload generate:importmap ``` Re-run this any time you change which login methods or Better Auth plugins are enabled. ## 7. Create the database schema [#7-create-the-database-schema] The plugin adds collections, which means new tables. ```bash pnpm payload migrate:create pnpm payload migrate ``` Set `push: true` on your database adapter and Payload syncs the schema on boot. Convenient locally, unsafe for production. See [Database & migrations](/docs/guides/database-migrations) for the details, including `generateSchema` for standalone adapter setups. ## 8. Start the app [#8-start-the-app] ```bash pnpm dev ``` Visit `http://localhost:3000/admin`. With no users in the database you are redirected to the signup view — continue with [Create your first admin](/docs/getting-started/first-admin). # Admin invitations (/docs/guides/admin-invitations) The `admin-invitations` collection is how new admins get created after the first one. Each record is a single-use token bound to a role. ## The collection [#the-collection] | Field | Notes | | ----------- | ------------------------------------------------------------------------ | | `role` | Which role the invitee receives; defaults to `users.defaultAdminRole` | | `token` | Random UUID; generated for you, regenerable from the admin UI | | `url` | The signup link, derived from `token` | | `expiresAt` | Required. Invitations generated through the endpoint expire after 7 days | Only users holding an admin role can read, create, update or delete these records. ## Inviting from the admin panel [#inviting-from-the-admin-panel] An invite button is rendered in the description area of the users collection. It calls `POST /api/users/generate-invite-url`, which validates that you are an admin, creates the invitation record, and returns the link. If you configured `adminInvitations.sendInviteEmail`, you can also have the link emailed via `POST /api/users/send-invite`. ```ts title="src/lib/auth/options.ts" export const payloadAuthOptions = { adminInvitations: { async sendInviteEmail({ payload, email, url }) { try { await payload.sendEmail({ to: email, subject: 'You have been invited to the admin panel', html: `Accept your invitation`, }) return { success: true } } catch (error) { return { success: false, message: 'Could not send invitation email' } } }, }, } satisfies PayloadAuthOptions ``` The callback must return `{ success: true }` or `{ success: false, message }`. Without it configured, the send endpoint returns a 500 and logs that the function is missing — generating a link and sharing it manually still works. ## Customising the invite URL [#customising-the-invite-url] By default the link is `/signup?token=`, built from `payload.getAdminURL()`. Set `payload.config.serverURL` so that resolves to an absolute URL. To point invitations at your own signup page instead: ```ts export const payloadAuthOptions = { adminInvitations: { generateInviteUrl: ({ payload, token }) => `https://app.example.com/join?token=${token}`, }, } satisfies PayloadAuthOptions ``` ## Inviting programmatically [#inviting-programmatically] ```ts import { getPayload } from '@/lib/payload' const payload = await getPayload() const token = crypto.randomUUID() await payload.create({ collection: 'admin-invitations', data: { token, role: 'admin', expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), }, }) ``` `expiresAt` is required — there is no default, so you must supply it. `url` is a virtual field: it is stripped on write and regenerated on read from the token, so do not pass it. ## Requiring an invitation for every sign-up [#requiring-an-invitation-for-every-sign-up] By default invitations gate the admin panel only — your app's public sign-up is untouched. To require an invitation for *all* sign-ups: ```ts betterAuthPlugin({ requireAdminInviteForSignUp: true, }) ``` With this on: * Both email/password and social sign-ups need a valid invitation token. * Existing users still sign in normally. * Admins can still create users directly in the Payload UI or via the Local API. * Provider-level `disableImplicitSignUp` and `disableSignUp` are overridden: with a valid invite the sign-up proceeds, without one it is blocked. * `disableImplicitSignUp` is set on every provider, so `authClient.signIn.social` calls that would create a new account must pass `requestSignUp: true`. This is the setting for an internal tool where OAuth exists for staff convenience and public registration should be impossible. ## Renaming or hiding the collection [#renaming-or-hiding-the-collection] ```ts betterAuthPlugin({ adminInvitations: { slug: 'invites', hidden: true, collectionOverrides: ({ collection }) => ({ ...collection, admin: { ...collection.admin, group: 'Administration' }, }), }, }) ``` # Database and migrations (/docs/guides/database-migrations) The plugin adds collections, so it adds tables. Payload owns the schema — the adapter never touches your database directly, it goes through the Local API. ## Any Payload adapter works [#any-payload-adapter-works] Postgres, SQLite, MongoDB and their variants are all supported, because everything runs through Payload. The one thing the plugin needs to know is your ID type, which it reads from `payload.db.defaultIDType` and passes to the adapter so IDs convert correctly in both directions. ## Development [#development] The quickest loop is Payload's push mode: ```ts title="src/payload.config.ts" db: postgresAdapter({ push: true, // development only pool: { connectionString: process.env.DATABASE_URI }, }) ``` Payload syncs the schema on boot. Convenient locally; never use it against production data. ## Production [#production] Use migrations: ```bash pnpm payload migrate:create # generate from the current config pnpm payload migrate # apply ``` A useful build script: ```json title="package.json" { "scripts": { "build:prod": "payload migrate && next build" } } ``` Re-run `migrate:create` whenever you change something that alters the schema: * enabling or disabling a Better Auth plugin that owns collections * renaming a collection with `slug` * adding fields through `collectionOverrides` or `user.additionalFields` * changing `users.roles`, which changes the `role` select options ## Inspecting what will be created [#inspecting-what-will-be-created] Set `debug.logTables` to have the plugin print the tables Better Auth requires on boot: ```ts betterAuthPlugin({ debug: { logTables: true }, }) ``` Pair it with `enableDebugLogs` to trace every adapter call — each database operation is logged with its inputs and outputs under the `[payload-db-adapter]` prefix: ```ts betterAuthPlugin({ debug: { enableDebugLogs: true, logTables: true }, }) ``` Both are noisy. Leave them off in production. ## Generating collection files [#generating-collection-files] `generateSchema` writes Payload collection configs derived from your Better Auth options. You do **not** need this when using `betterAuthPlugin` — it builds the collections in memory. It is for standalone adapter setups, or for reading what the schema looks like. ```ts title="src/bin/schema-gen.ts" import { generateSchema } from 'payload-auth/better-auth/adapter' import { betterAuthOptions } from '@/lib/auth/options' await generateSchema(betterAuthOptions, { outputDir: './src/payload/schema', }) ``` ```bash pnpm tsx src/bin/schema-gen.ts ``` This writes `schema.ts` into the output directory, merging with anything already there. Generated collections are a convenience, not a finished product. Review the access control and field configuration before shipping them. ## Using the adapter without the plugin [#using-the-adapter-without-the-plugin] If you want Better Auth backed by Payload but not the generated collections and admin views, use the adapter on its own: ```ts title="src/lib/auth.ts" import { betterAuth } from 'better-auth' import { payloadAdapter } from 'payload-auth/better-auth/adapter' import type { BasePayload } from 'payload' export function auth(payload: BasePayload) { return betterAuth({ database: payloadAdapter({ payloadClient: payload, adapterConfig: { idType: payload.db.defaultIDType, enableDebugLogs: false, }, }), // … your options }) } ``` You then own everything the plugin normally handles: declaring the collections, mapping `modelName` to your slugs, mapping field names such as `userId` → `user`, and access control. See [Adapter](/docs/reference/adapter). ## Cleaning up expired records [#cleaning-up-expired-records] Sessions and verifications accumulate. Better Auth deletes expired sessions as it encounters them, but a periodic sweep keeps the tables small: ```ts const payload = await getPayload() await payload.delete({ collection: 'sessions', where: { expiresAt: { less_than: new Date().toISOString() } }, }) await payload.delete({ collection: 'verifications', where: { expiresAt: { less_than: new Date().toISOString() } }, }) ``` # Email and password (/docs/guides/email-password) Email and password authentication is **enabled by default**. If you set no `emailAndPassword` block at all, the plugin turns it on so the admin panel has a way in. ## Configuration [#configuration] ```ts title="src/lib/auth/options.ts" export const betterAuthOptions = { emailAndPassword: { enabled: true, requireEmailVerification: true, autoSignIn: false, minPasswordLength: 8, async sendResetPassword({ user, url, token }) { await sendEmail({ to: user.email, subject: 'Reset your password', html: `Reset your password`, }) }, }, } satisfies BetterAuthOptions ``` To turn it off — a panel that only accepts social login or passkeys — set `enabled: false` explicitly. ## Email verification [#email-verification] ```ts export const betterAuthOptions = { emailVerification: { sendOnSignUp: true, autoSignInAfterVerification: true, async sendVerificationEmail({ user, url }) { await sendEmail({ to: user.email, subject: 'Verify your email', html: `Verify your email`, }) }, }, } satisfies BetterAuthOptions ``` The verification state lands in the `emailVerified` checkbox on the users collection, so you can see and (as an admin) correct it from the panel. Log the URL instead of sending it and paste it into your browser. See [Create your first admin](/docs/getting-started/first-admin). ## Sending real email [#sending-real-email] Better Auth's callbacks are the place to send mail — they receive the recipient and the signed URL, and it is up to you how to deliver it. If you already configured Payload's email adapter, reuse it: ```ts title="src/lib/auth/options.ts" import { getPayload } from '@/lib/payload' async function sendVerificationEmail({ user, url }) { const payload = await getPayload() await payload.sendEmail({ to: user.email, subject: 'Verify your email', html: `Verify your email`, }) } ``` ## Password resets from the admin panel [#password-resets-from-the-admin-panel] `/admin/forgot-password` and `/admin/reset-password` are wired to Better Auth's reset flow. The forgot-password form calls Better Auth, which invokes your `emailAndPassword.sendResetPassword` callback. If you have not implemented that callback, the form appears to succeed but no email is sent. ## Changing a password [#changing-a-password] Users change their own password through the Better Auth client: ```ts await authClient.changePassword({ currentPassword, newPassword, revokeOtherSessions: true, }) ``` Through Payload's API instead, send both `currentPassword` and `password` — the plugin verifies the current password before permitting the update. See [Roles and access control](/docs/concepts/roles-and-access). ## Related options [#related-options] | Option | Effect | | ----------------------------- | -------------------------------------------------------- | | `requireEmailVerification` | Block sign-in until the address is verified | | `autoSignIn` | Sign the user in immediately after sign-up | | `account.accountLinking` | Link a social account to an existing email/password user | | `requireAdminInviteForSignUp` | Require an invitation for *all* public sign-ups | For the full set, see the [Better Auth options reference](https://www.better-auth.com/docs/reference/options). # Organizations and teams (/docs/guides/organizations) The Better Auth `organization` plugin is supported, including teams. Its records become ordinary Payload collections with real relationships, so you can browse and edit them in the admin panel. ## Enable it [#enable-it] ```ts title="src/lib/auth/options.ts" import { organization } from 'better-auth/plugins' export const betterAuthOptions = { plugins: [ organization({ teams: { enabled: true }, async sendInvitationEmail(data) { const url = `${process.env.NEXT_PUBLIC_BETTER_AUTH_URL}/accept-invitation/${data.id}` await sendEmail({ to: data.email, subject: 'You are invited', html: `Accept` }) }, }), ], } satisfies BetterAuthOptions ``` ```ts title="src/lib/auth/client.ts" import { organizationClient } from 'better-auth/client/plugins' export const authClient = createAuthClient({ plugins: [organizationClient()], }) ``` ## Collections created [#collections-created] | Slug | Contents | | ------------------- | ---------------------------------------------- | | `organizations` | name, slug, logo, metadata | | `members` | `user` + `organization` relationships, role | | `invitations` | `organization`, `inviter`, email, role, status | | `organizationRoles` | custom roles scoped to an organization | | `teams` | name, `organization` relationship | | `teamMembers` | `team` + `user` relationships | `teams` and `teamMembers` only appear when `teams.enabled` is true. Better Auth's `organizationId`, `userId`, `inviterId` and `teamId` keys are mapped to Payload relationship fields named `organization`, `user`, `inviter` and `team`. Write Payload queries against the Payload names: ```ts const members = await payload.find({ collection: 'members', where: { organization: { equals: organizationId } }, }) ``` ## Active organization [#active-organization] The active organization is stored on the session record as `activeOrganizationId`: ```ts await authClient.organization.setActive({ organizationId }) ``` ```ts const { data: activeOrg } = authClient.useActiveOrganization() const { data: orgs } = authClient.useListOrganizations() ``` ## Permissions [#permissions] Define an access controller and pass it to the plugin, exactly as Better Auth documents: ```ts import { createAccessControl } from 'better-auth/plugins' import { defaultStatements, defaultRoles, ownerAc, } from 'better-auth/plugins/organization/access' const ac = createAccessControl({ ...defaultStatements, project: ['create', 'share', 'update', 'delete'], }) const superAdmin = ac.newRole({ project: ['create', 'update'], ...ownerAc.statements, }) export const betterAuthOptions = { plugins: [ organization({ ac, roles: { ...defaultRoles, superAdmin } }), ], } satisfies BetterAuthOptions ``` `users.roles` in the plugin options governs the Payload admin panel. Organization roles (`owner`, `admin`, `member`, or your own) govern membership within an organization. They are independent — a Payload admin is not automatically an organization owner. ## Scoping your own collections [#scoping-your-own-collections] Add an `organization` relationship to your collections and filter on it in access control: ```ts title="src/collections/projects.ts" import type { CollectionConfig } from 'payload' export const Projects: CollectionConfig = { slug: 'projects', access: { read: async ({ req }) => { if (!req.user) return false const members = await req.payload.find({ collection: 'members', where: { user: { equals: req.user.id } }, depth: 0, }) const orgIds = members.docs.map((m) => m.organization) return { organization: { in: orgIds } } }, }, fields: [ { name: 'name', type: 'text', required: true }, { name: 'organization', type: 'relationship', relationTo: 'organizations', required: true, }, ], } ``` ## Types [#types] ```ts title="src/lib/auth/types.ts" import type { getPayload } from '@/lib/payload' type PayloadWithAuth = Awaited> export type ActiveOrganization = PayloadWithAuth['betterAuth']['$Infer']['ActiveOrganization'] export type Invitation = PayloadWithAuth['betterAuth']['$Infer']['Invitation'] ``` # Passkeys and two-factor (/docs/guides/passkeys-and-2fa) Both plugins are supported first-class: the plugin generates their collections and wires their admin UI. ## Passkeys [#passkeys] ### Install and enable [#install-and-enable] npm pnpm yarn bun ```bash npm install @better-auth/passkey ``` ```bash pnpm add @better-auth/passkey ``` ```bash yarn add @better-auth/passkey ``` ```bash bun add @better-auth/passkey ``` ```ts title="src/lib/auth/options.ts" import { passkey } from '@better-auth/passkey' export const betterAuthOptions = { plugins: [ passkey({ rpID: 'localhost', rpName: 'My App', origin: 'http://localhost:3000', }), ], } satisfies BetterAuthOptions ``` `rpID` is the registrable domain without protocol or port (`localhost` in development, `example.com` in production). `origin` is the full origin. Both must match what the browser sees or WebAuthn refuses to run. ### Add the client plugin [#add-the-client-plugin] ```ts title="src/lib/auth/client.ts" import { passkeyClient } from '@better-auth/passkey/client' export const authClient = createAuthClient({ plugins: [passkeyClient()], }) ``` ### Regenerate the import map [#regenerate-the-import-map] ```bash pnpm payload generate:importmap ``` The passkey button appears on the admin login view automatically, because `setLoginMethods` adds `passkey` when the plugin is detected. This creates a `passkeys` collection with the credential ID, public key, counter and a `user` relationship. ### Managing passkeys [#managing-passkeys] A `Passkeys` server component is exported for building a management screen: ```tsx import { Passkeys } from 'payload-auth/better-auth/plugin/rsc' ``` Or drive it from the client: ```ts await authClient.passkey.addPasskey({ name: 'MacBook' }) const { data } = await authClient.passkey.listUserPasskeys() await authClient.passkey.deletePasskey({ id }) ``` ## Two-factor authentication [#two-factor-authentication] ### Enable the plugin [#enable-the-plugin] ```ts title="src/lib/auth/options.ts" import { twoFactor } from 'better-auth/plugins' export const betterAuthOptions = { plugins: [ twoFactor({ issuer: 'My App', otpOptions: { async sendOTP({ user, otp }) { await sendEmail({ to: user.email, subject: 'Your code', text: otp }) }, }, }), ], } satisfies BetterAuthOptions ``` `issuer` is the label shown in authenticator apps. ### Add the client plugin [#add-the-client-plugin-1] ```ts title="src/lib/auth/client.ts" import { twoFactorClient } from 'better-auth/client/plugins' export const authClient = createAuthClient({ plugins: [ twoFactorClient({ onTwoFactorRedirect() { window.location.href = '/two-factor' }, }), ], }) ``` ### Regenerate the import map [#regenerate-the-import-map-1] ```bash pnpm payload generate:importmap ``` This creates a `twoFactors` collection (secret, backup codes, `user` relationship), adds a `twoFactorEnabled` checkbox to `users`, and registers the `/admin/two-factor-verify` view. ### The admin challenge [#the-admin-challenge] When an admin with 2FA enabled signs in, they are redirected to `/admin/two-factor-verify`, which accepts a TOTP code or a backup code. This view is only registered when the `twoFactor` plugin is present. ### Enrolling [#enrolling] ```ts const { data } = await authClient.twoFactor.enable({ password }) // data.totpURI → render as a QR code // data.backupCodes → show once, tell the user to store them await authClient.twoFactor.verifyTotp({ code }) ``` A `TwoFactorAuth` client component that handles enrolment (QR code, verification, backup codes) is exported for reuse: ```tsx import { TwoFactorAuth } from 'payload-auth/better-auth/plugin/client' ``` If a user enables 2FA and loses both their authenticator and their backup codes, they cannot sign in. As an admin you can clear the situation by deleting their `twoFactors` record and unchecking `twoFactorEnabled` on their user document in the admin panel. ## Other passwordless methods [#other-passwordless-methods] `magicLink`, `emailOTP`, `phoneNumber` and `anonymous` are all supported. Enable the Better Auth plugin, add its client counterpart, and — if you want it on the admin login screen — list the matching method in `admin.loginMethods` (`magicLink`, `emailOTP`, `phoneOTP`, `phoneMagicLink`, `phonePassword`). # Server-side usage (/docs/guides/server-side) On the server, `payload.betterAuth` gives you the full Better Auth instance — no HTTP round trip. ## The typed accessor [#the-typed-accessor] Define this once and import it everywhere: ```ts title="src/lib/payload.ts" import configPromise from '@payload-config' import { getPayloadAuth } from 'payload-auth/better-auth' import type { PayloadAuthConfig } from './auth/options' export const getPayload = async () => getPayloadAuth(configPromise) ``` `getPayloadAuth` wraps Payload's `getPayload` and throws a clear error if the plugin is not installed. Passing your options type as the generic is what makes `betterAuth.api`, `$Infer` and `$ERROR_CODES` reflect your actual plugin set. ## Reading the session [#reading-the-session] ```tsx title="src/app/dashboard/page.tsx" import { headers } from 'next/headers' import { redirect } from 'next/navigation' import { getPayload } from '@/lib/payload' export default async function DashboardPage() { const payload = await getPayload() const session = await payload.betterAuth.api.getSession({ headers: await headers(), }) if (!session) redirect('/sign-in') return

Hello {session.user.name}

} ``` For the full Payload user document — including fields excluded from the session cookie — use `payload.auth()`, which runs the plugin's auth strategy: ```ts const { user } = await payload.auth({ headers: await headers() }) ``` | Call | Returns | | ----------------------------- | --------------------------------------------------------- | | `betterAuth.api.getSession()` | Better Auth `{ session, user }`, limited to cached fields | | `payload.auth()` | The complete Payload user document | ## Calling any Better Auth endpoint [#calling-any-better-auth-endpoint] Everything under `betterAuth.api` is available server-side and mirrors the HTTP API: ```ts const payload = await getPayload() const requestHeaders = await headers() const accounts = await payload.betterAuth.api.listUserAccounts({ headers: requestHeaders }) const sessions = await payload.betterAuth.api.listSessions({ headers: requestHeaders }) await payload.betterAuth.api.signUpEmail({ body: { email: 'new@example.com', password: 'a-strong-password', name: 'New User' }, }) ``` Endpoints that mutate the session accept `asResponse: true` when you need the `Set-Cookie` headers: ```ts export async function POST(request: Request) { const payload = await getPayload() return payload.betterAuth.api.signInEmail({ body: await request.json(), asResponse: true, }) } ``` ## Fetching in parallel [#fetching-in-parallel] Server components can start several reads at once and let React suspend on them: ```ts export const getContextProps = () => ({ sessionPromise: getSession(), userAccountsPromise: getUserAccounts(), deviceSessionsPromise: getDeviceSessions(), }) ``` ## Types [#types] Derive everything from the accessor so types follow your config automatically: ```ts title="src/lib/auth/types.ts" import type { getPayload } from '@/lib/payload' type PayloadWithAuth = Awaited> export type Session = PayloadWithAuth['betterAuth']['$Infer']['Session'] export type User = Session['user'] export type ErrorCodes = PayloadWithAuth['betterAuth']['$ERROR_CODES'] export type Account = Awaited< ReturnType >[number] ``` `$ERROR_CODES` includes Better Auth's base codes plus the codes contributed by every plugin you enabled, which makes error handling exhaustive: ```ts const { error } = await authClient.signIn.email({ email, password }) if (error?.code === payload.betterAuth.$ERROR_CODES.INVALID_EMAIL_OR_PASSWORD) { // … } ``` ## Querying auth data with the Local API [#querying-auth-data-with-the-local-api] The generated collections are ordinary Payload collections: ```ts const payload = await getPayload() const active = await payload.find({ collection: 'sessions', where: { expiresAt: { greater_than: new Date().toISOString() } }, depth: 0, }) const admins = await payload.find({ collection: 'users', where: { role: { contains: 'admin' } }, }) ``` In `where` clauses, use the Payload names, not the Better Auth keys — `user` rather than `userId` on `sessions` and `accounts`. See [Collections](/docs/concepts/collections) for the mapping. ## Route handlers [#route-handlers] The catch-all route mounted during installation is all Better Auth needs: ```ts title="src/app/api/auth/[...all]/route.ts" import { toNextJsHandler } from 'better-auth/next-js' import { getPayload } from '@/lib/payload' const payload = await getPayload() export const { POST, GET } = toNextJsHandler(payload.betterAuth) ``` For your own routes, gate on the session: ```ts title="src/app/api/projects/route.ts" import { headers } from 'next/headers' import { getPayload } from '@/lib/payload' export async function GET() { const payload = await getPayload() const session = await payload.betterAuth.api.getSession({ headers: await headers(), }) if (!session) return Response.json({ error: 'Unauthorized' }, { status: 401 }) const projects = await payload.find({ collection: 'projects', where: { owner: { equals: session.user.id } }, }) return Response.json(projects) } ``` ## Server actions [#server-actions] ```ts title="src/lib/actions/auth.ts" 'use server' import { headers } from 'next/headers' import { revalidatePath } from 'next/cache' import { getPayload } from '@/lib/payload' export async function updateName(name: string) { const payload = await getPayload() const session = await payload.betterAuth.api.getSession({ headers: await headers(), }) if (!session) throw new Error('Unauthorized') await payload.update({ collection: 'users', id: session.user.id, data: { name }, }) revalidatePath('/settings') } ``` `nextCookies()` writes Better Auth's `Set-Cookie` headers through Next's `cookies()` API so they survive server actions. Keep it last in your `plugins` array. If the admin panel starts looping on `buildFormState` requests, see [Troubleshooting](/docs/troubleshooting). # Social providers (/docs/guides/social-providers) Social providers are configured entirely through `betterAuthOptions.socialProviders`. The plugin reads that object to decide which buttons the admin login and signup views render. ## Configuration [#configuration] ```ts title="src/lib/auth/options.ts" export const betterAuthOptions = { socialProviders: { google: { clientId: process.env.GOOGLE_CLIENT_ID as string, clientSecret: process.env.GOOGLE_CLIENT_SECRET as string, }, github: { clientId: process.env.GITHUB_CLIENT_ID as string, clientSecret: process.env.GITHUB_CLIENT_SECRET as string, }, }, } satisfies BetterAuthOptions ``` Each provider's callback URL is `/api/auth/callback/` — for example `http://localhost:3000/api/auth/callback/google`. Register that exact URL with the provider. ## Providers with an admin button [#providers-with-an-admin-button] The admin views ship branded buttons for: `apple`, `discord`, `facebook`, `github`, `google`, `linkedin`, `microsoft`, `spotify`, `tiktok`, `twitter`, `twitch`, `zoom`, `gitlab`, `roblox`, `vk`, `kick`, `reddit`. Any other Better Auth provider still works on your frontend — it just has no built-in button in the Payload admin views. ## Controlling which buttons appear [#controlling-which-buttons-appear] By default the admin views show a button for every configured provider, plus email/password and passkey when those are enabled. Override with `admin.loginMethods`: ```ts betterAuthPlugin({ admin: { loginMethods: ['google', 'passkey'] }, betterAuthOptions: { emailAndPassword: { enabled: true }, // still available to your frontend socialProviders: { google: { clientId: '…', clientSecret: '…' }, github: { clientId: '…', clientSecret: '…' }, }, }, }) ``` Here the panel offers Google and passkeys only, while your own app can still use GitHub and email/password. Re-run `payload generate:importmap` after changing this — the buttons are components resolved through the import map. ## Signing in from your app [#signing-in-from-your-app] ```tsx await authClient.signIn.social({ provider: 'google', callbackURL: '/dashboard', }) ``` ## Account linking [#account-linking] To let an existing email/password user attach a Google account with the same address: ```ts export const betterAuthOptions = { account: { accountLinking: { enabled: true, trustedProviders: ['google', 'email-password'], }, }, } satisfies BetterAuthOptions ``` Linked providers become rows in the `accounts` collection, visible in the admin panel. Automatic linking on a matching email address is an account-takeover risk with any provider that does not verify ownership of the address. Keep `trustedProviders` to providers you trust. ## Restricting sign-ups to invited users [#restricting-sign-ups-to-invited-users] To use OAuth for staff access only, with no public registration: ```ts betterAuthPlugin({ requireAdminInviteForSignUp: true, }) ``` Existing users still sign in. New accounts require a valid admin invitation, across every provider and email/password alike. The plugin also sets `disableImplicitSignUp` on all providers, so `authClient.signIn.social` calls that would create an account must pass `requestSignUp: true`. See [Admin invitations](/docs/guides/admin-invitations). ## Secrets [#secrets] Client secrets are stripped before plugin options are handed to the admin views, because Payload serialises server props into the admin HTML. Keep them in environment variables regardless — see [Admin panel integration](/docs/concepts/admin-panel). # Adapter (/docs/reference/adapter) `payloadAdapter` implements Better Auth's `DBAdapter` interface on top of Payload's Local API. `betterAuthPlugin` installs it for you — you only construct it directly when using Better Auth with Payload storage but without the plugin. ```ts import { payloadAdapter } from 'payload-auth/better-auth/adapter' ``` ## Signature [#signature] ```ts payloadAdapter({ payloadClient: BasePayload | Promise | (() => Promise), adapterConfig: { idType: 'number' | 'text' enableDebugLogs?: boolean }, }): DBAdapterInstance ``` ## Standalone usage [#standalone-usage] ```ts title="src/lib/auth.ts" import { betterAuth } from 'better-auth' import { payloadAdapter } from 'payload-auth/better-auth/adapter' import type { BasePayload } from 'payload' export function auth(payload: BasePayload) { return betterAuth({ database: payloadAdapter({ payloadClient: payload, adapterConfig: { idType: payload.db.defaultIDType }, }), user: { modelName: 'users' }, session: { modelName: 'sessions', fields: { userId: 'user' }, }, account: { modelName: 'accounts', fields: { userId: 'user' }, }, verification: { modelName: 'verifications' }, }) } ``` Better Auth is constructed per-request from a Payload instance, which is why this is a function rather than a module-level constant. Without the plugin, nothing rewrites `modelName` or field names for you. Payload slugs are usually plural (`users`, `sessions`) while Better Auth models are singular, and foreign keys become relationships (`userId` → `user`). Every mapping must be declared, or the adapter will fail to find collections and fields at runtime. ## What the adapter translates [#what-the-adapter-translates] **Collection slugs.** Better Auth model names are resolved to Payload slugs. A missing collection throws `BetterAuthError: Collection does not exist`. **IDs.** Better Auth expects strings everywhere. Payload may use numbers. The adapter stringifies on output and converts back on input, using `idType`. **Field names.** Configured `fields` mappings are applied on the way in and reversed on the way out, so Better Auth always sees `userId` even though Payload stores `user`. **Operators.** | Better Auth | Payload | | ------------- | -------------------- | | `eq` | `equals` | | `ne` | `not_equals` | | `gt` | `greater_than` | | `gte` | `greater_than_equal` | | `lt` | `less_than` | | `lte` | `less_than_equal` | | `in` | `in` | | `contains` | `contains` | | `starts_with` | `like` | | `ends_with` | `like` | **Dates.** ISO strings from Payload become `Date` objects. **Depth.** Every query runs at `depth: 0`, so relationships come back as raw IDs rather than populated documents. ## Methods [#methods] | Method | Payload API | | ------------ | --------------------------------------------------------------------------------------------------- | | `create` | `payload.create()` | | `findOne` | `payload.findByID()` when the where clause is `id equals X`, otherwise `payload.find({ limit: 1 })` | | `findMany` | `payload.find()` | | `update` | `payload.update()` by ID when possible, otherwise by where | | `updateMany` | `payload.update({ where })` | | `delete` | `payload.delete()` | | `deleteMany` | `payload.delete({ where })` | | `count` | `payload.count()` | ## `generateSchema(options, config)` [#generateschemaoptions-config] ```ts function generateSchema( options: BetterAuthOptions, config?: { outputDir: string }, // default './generated' ): Promise ``` Writes `schema.ts` into `outputDir` containing Payload collection configs derived from your Better Auth options, merging with any existing file. Returns the generated source. ```ts title="src/bin/schema-gen.ts" import { generateSchema } from 'payload-auth/better-auth/adapter' import { betterAuthOptions } from '@/lib/auth/options' await generateSchema(betterAuthOptions, { outputDir: './src/payload/schema' }) ``` You do not need this when using `betterAuthPlugin` — it builds collections in memory. Treat the output as a starting point and review access control before shipping. ## Debugging [#debugging] ```ts payloadAdapter({ payloadClient: payload, adapterConfig: { idType: payload.db.defaultIDType, enableDebugLogs: true }, }) ``` Through the plugin, the equivalent is: ```ts betterAuthPlugin({ debug: { enableDebugLogs: true } }) ``` Errors are always logged, regardless of the flag. # Better Auth plugin support (/docs/reference/better-auth-plugins) Every Better Auth plugin can be used. They fall into two groups: * **Configured** — the plugin generates their collections and rewrites their `modelName` and field mappings so they address your Payload slugs. * **Pass-through** — they need no storage or no mapping, so they are handed to Better Auth untouched and behave exactly as their own docs describe. Nothing is filtered out. A plugin the integration does not specifically know about still reaches Better Auth. ## Configured plugins [#configured-plugins] | Plugin | Collections generated | | -------------------------------- | -------------------------------------------------------------- | | `admin` | — (drives roles, ban, impersonation) | | `twoFactor` | `twoFactors` | | `passkey` | `passkeys` | | `apiKey` | `apiKeys` | | `organization` | `organizations`, `members`, `invitations`, `organizationRoles` | | `organization` (`teams.enabled`) | `teams`, `teamMembers` | | `sso` | `ssoProviders` | | `oidc` | `oauthApplications`, `oauthAccessTokens`, `oauthConsents` | | `deviceAuthorization` | `deviceCode` | These get a configurator that sets their model names and field mappings. The `admin` plugin additionally receives role-normalising middleware, so Payload's array-valued `role` field and Better Auth's string role stay consistent in both directions. ## Plugins with generated collections [#plugins-with-generated-collections] Some plugins own storage without a dedicated configurator — their collections are still created from the schema: | Plugin | Collections | | ---------------------- | --------------- | | `stripe` | `subscriptions` | | `scim` | `scimProvider` | | `jwt` | `jwks` | | database rate limiting | `rateLimit` | ## Pass-through plugins [#pass-through-plugins] Recognised and passed straight to Better Auth: `oneTimeToken`, `oAuthProxy`, `haveIBeenPwned`, `captcha`, `bearer`, `genericOAuth`, `customSession`, `harmonyEmail`, `harmonyPhoneNumber`, `username`, `anonymous`, `phoneNumber`, `magicLink`, `emailOtp`, `oneTap`, `mcp`, `multiSession`, `openApi`, `nextCookies`, `expo`, `polar`, `autumn`, `dodopayments`, `dubAnalytics`, `lastLoginMethod`. Several of these do affect the admin UI even though they need no mapping: * **`username`** — the login and signup views accept a username. Which identifiers appear follows `auth.loginWithUsername` on your users collection. * **`magicLink`, `emailOtp`, `phoneNumber`** — become selectable `admin.loginMethods`. * **`nextCookies`** — required for Better Auth cookies to survive Next.js server actions. ## Notes per plugin [#notes-per-plugin] ### `admin` [#admin] ```ts import { admin } from 'better-auth/plugins' plugins: [admin()] ``` Your `users.adminRoles` and `users.defaultRole` are forwarded to it, so you configure roles once. Enabling it also reveals the Impersonate / Revoke Sessions / Ban buttons on the user edit view. ### `nextCookies` [#nextcookies] Must be **last** in the array. See [Troubleshooting](/docs/troubleshooting) if the admin panel starts looping on `buildFormState` requests. ```ts import { nextCookies } from 'better-auth/next-js' plugins: [admin(), organization(), nextCookies()] ``` ### `openAPI` [#openapi] Serves an interactive reference of every auth endpoint your configuration exposes — useful for confirming what the generated setup actually offers. ```ts import { openAPI } from 'better-auth/plugins' plugins: [openAPI()] ``` ### Separately published plugins [#separately-published-plugins] Some plugins ship as their own packages and are peer or optional dependencies: npm pnpm yarn bun ```bash npm install @better-auth/passkey @better-auth/api-key @better-auth/sso @better-auth/stripe @better-auth/scim ``` ```bash pnpm add @better-auth/passkey @better-auth/api-key @better-auth/sso @better-auth/stripe @better-auth/scim ``` ```bash yarn add @better-auth/passkey @better-auth/api-key @better-auth/sso @better-auth/stripe @better-auth/scim ``` ```bash bun add @better-auth/passkey @better-auth/api-key @better-auth/sso @better-auth/stripe @better-auth/scim ``` ```ts import { passkey } from '@better-auth/passkey' import { apiKey } from '@better-auth/api-key' ``` ## After changing plugins [#after-changing-plugins] Enabling or disabling a plugin changes both the collection set and the admin components: ```bash pnpm payload generate:importmap pnpm payload migrate:create pnpm payload migrate ``` # Exports (/docs/reference/exports) ## Entry points [#entry-points] | Import path | Contents | | ---------------------------------------- | -------------------------------------------- | | `payload-auth` | Everything below re-exported | | `payload-auth/better-auth` | Plugin, adapter and types — the usual import | | `payload-auth/better-auth/plugin` | Plugin only | | `payload-auth/better-auth/adapter` | Adapter only | | `payload-auth/better-auth/plugin/client` | Client components (`'use client'`) | | `payload-auth/better-auth/plugin/rsc` | React Server Components and views | | `payload-auth/shared/payload/fields` | Reusable Payload field components | ## `payload-auth/better-auth` [#payload-authbetter-auth] ### `betterAuthPlugin(options)` [#betterauthpluginoptions] ```ts function betterAuthPlugin(pluginOptions: PayloadAuthOptions): (config: Config) => Config ``` The Payload plugin. See [Plugin options](/docs/reference/plugin-options). ### `getPayloadAuth(config)` [#getpayloadauthconfig] ```ts function getPayloadAuth( config: Promise | SanitizedConfig, ): Promise }> ``` Payload's `getPayload` with the Better Auth instance attached and typed. Throws if the plugin is not installed. ```ts import configPromise from '@payload-config' import { getPayloadAuth } from 'payload-auth/better-auth' import type { PayloadAuthConfig } from './auth/options' const payload = await getPayloadAuth(configPromise) ``` ### `generateVerifyEmailUrl(options)` [#generateverifyemailurloptions] ```ts function generateVerifyEmailUrl(options: { userEmail: string secret: string verifyRouteUrl: string callbackURL?: string // default '/' expiresIn?: number // seconds, default 3600 }): Promise ``` Signs a JWT containing the email address and builds a verification URL. Useful for seeding or for custom verification flows. The `callbackURL` is passed through a safe-redirect check before being appended. ### `sanitizeBetterAuthOptions(...)` [#sanitizebetterauthoptions] The internal function that rewrites `PayloadAuthOptions` into resolved `BetterAuthOptions`. Exported for inspection and testing; you rarely call it directly. ## Types [#types] ```ts import type { BetterAuthOptions, BetterAuthReturn, PayloadAuthOptions, LoginMethod, SocialProvider, SendAdminInviteEmailFn, GenerateAdminInviteUrlFn, PayloadRequestWithBetterAuth, CollectionHookWithBetterAuth, EndpointWithBetterAuth, } from 'payload-auth/better-auth' ``` | Type | Purpose | | ------------------------------------ | ------------------------------------------------------------------------------------------------------- | | `PayloadAuthOptions` | The plugin's options object | | `BetterAuthOptions` | Better Auth's options with `database` removed | | `BetterAuthReturn` | The instance on `payload.betterAuth`: `handler`, `api`, `options`, `$Infer`, `$ERROR_CODES`, `$context` | | `LoginMethod` | Union of valid `admin.loginMethods` values | | `SocialProvider` | Union of providers with a built-in admin button | | `PayloadRequestWithBetterAuth` | `PayloadRequest` whose `payload` carries `betterAuth` | | `CollectionHookWithBetterAuth` | Type a Payload hook so `req.payload.betterAuth` is available | | `EndpointWithBetterAuth` | Type a Payload endpoint the same way | Typing a custom hook: ```ts import type { CollectionHookWithBetterAuth, PayloadAuthOptions, } from 'payload-auth/better-auth' import type { CollectionAfterChangeHook } from 'payload' import type { PayloadAuthConfig } from '@/lib/auth/options' type AfterChange = CollectionHookWithBetterAuth< PayloadAuthConfig, CollectionAfterChangeHook > export const onUserChange: AfterChange = async ({ doc, req }) => { const session = await req.payload.betterAuth.api.getSession({ headers: req.headers }) // … return doc } ``` ## `payload-auth/better-auth/plugin/client` [#payload-authbetter-authpluginclient] Client components, all marked `'use client'`. | Export | Purpose | | -------------------- | ------------------------------------------------------------------- | | `AdminButtons` | Impersonate / revoke sessions / ban / unban, for the user edit view | | `AdminInviteButton` | Generates an admin invitation link | | `LogoutButton` | Ends the Better Auth session | | `CredentialsForm` | Email (or username) and password form | | `AlternativeMethods` | Social and passwordless buttons | | `LoginFormProvider` | Context provider for the login form pieces | | `useLoginForm` | Hook exposing the login form state | | `TwoFactorAuth` | 2FA enrolment: QR code, verification, backup codes | ## `payload-auth/better-auth/plugin/rsc` [#payload-authbetter-authpluginrsc] Server components and admin views. | Export | Purpose | | ----------------- | -------------------------------------- | | `AdminLogin` | The `/admin/login` view | | `AdminSignup` | The `/admin/signup` view | | `ForgotPassword` | The `/admin/forgot-password` view | | `ResetPassword` | The `/admin/reset-password` view | | `TwoFactorVerify` | The `/admin/two-factor-verify` view | | `Passkeys` | Passkey management list | | `RSCRedirect` | Redirect helper used by the login flow | ## `payload-auth/shared/payload/fields` [#payload-authsharedpayloadfields] Field components you can attach to your own collections: | Export | Purpose | | -------------------- | --------------------------------------- | | `FieldCopyButton` | Copy-to-clipboard button beside a field | | `GenerateUuidButton` | Fill a text field with a fresh UUID | ```ts { name: 'token', type: 'text', admin: { components: { afterInput: [{ path: 'payload-auth/shared/payload/fields#GenerateUuidButton' }], }, }, } ``` ## `payload-auth/better-auth/adapter` [#payload-authbetter-authadapter] | Export | Purpose | | ---------------- | --------------------------------------------------------- | | `payloadAdapter` | The Better Auth `DBAdapter` implementation | | `generateSchema` | Write Payload collection configs from Better Auth options | See [Adapter](/docs/reference/adapter). # Plugin options (/docs/reference/plugin-options) `betterAuthPlugin(options: PayloadAuthOptions)` returns a Payload config transformer. ```ts import { betterAuthPlugin, type PayloadAuthOptions } from 'payload-auth/better-auth' ``` ## Top level [#top-level] ### `admin.loginMethods` [#adminloginmethods] Valid values: `emailPassword`, `magicLink`, `emailOTP`, `phonePassword`, `phoneOTP`, `phoneMagicLink`, `passkey`, and the social providers `apple`, `discord`, `facebook`, `github`, `google`, `linkedin`, `microsoft`, `spotify`, `tiktok`, `twitter`, `twitch`, `zoom`, `gitlab`, `roblox`, `vk`, `kick`, `reddit`. If you leave it unset, the plugin infers the list from `socialProviders`, `emailAndPassword.enabled` and the presence of the `passkey` plugin. ### `pluginCollectionOverrides` keys [#plugincollectionoverrides-keys] `subscriptions`, `apiKeys`, `jwks`, `twoFactors`, `passkeys`, `oauthApplications`, `oauthAccessTokens`, `oauthConsents`, `ssoProviders`, `organizations`, `organizationRoles`, `invitations`, `members`, `teams`, `teamMembers`, `scimProvider`, `rateLimit`, `deviceCode`. TypeScript cannot tell which Better Auth plugins you enabled, so it will let you configure a collection that does not exist. That is validated at runtime. ## `users` [#users] ## `accounts`, `sessions`, `verifications` [#accounts-sessions-verifications] All three take the same shape: ## `adminInvitations` [#admininvitations] ## `betterAuthOptions` [#betterauthoptions] This is Better Auth's own options object with `database` removed — the plugin supplies the adapter. Everything else behaves as the [Better Auth options reference](https://www.better-auth.com/docs/reference/options) describes. The plugin rewrites parts of it before handing it to Better Auth: * `user`, `session`, `account` and `verification` `modelName` are set to your Payload slugs. * `session.fields.userId` and `account.fields.userId` are pointed at the `user` relationship. * `emailAndPassword.enabled` defaults to `true`. * Supported plugins get their `modelName` and field mappings configured. * `saveToJWT: false` on Payload fields is mirrored onto the returned-field set, so excluded fields never reach the cookie cache. ## A worked example [#a-worked-example] ```ts title="src/lib/auth/options.ts" import type { BetterAuthOptions, PayloadAuthOptions } from 'payload-auth/better-auth' import { admin, organization, twoFactor } from 'better-auth/plugins' import { passkey } from '@better-auth/passkey' import { nextCookies } from 'better-auth/next-js' export const betterAuthOptions = { appName: 'my-app', baseURL: process.env.NEXT_PUBLIC_BETTER_AUTH_URL, trustedOrigins: [process.env.NEXT_PUBLIC_BETTER_AUTH_URL!], emailAndPassword: { enabled: true, requireEmailVerification: true, async sendResetPassword({ user, url }) { await sendEmail({ to: user.email, subject: 'Reset your password', html: url }) }, }, emailVerification: { sendOnSignUp: true, autoSignInAfterVerification: true, async sendVerificationEmail({ user, url }) { await sendEmail({ to: user.email, subject: 'Verify your email', html: url }) }, }, socialProviders: { google: { clientId: process.env.GOOGLE_CLIENT_ID as string, clientSecret: process.env.GOOGLE_CLIENT_SECRET as string, }, }, session: { cookieCache: { enabled: true, maxAge: 5 * 60 }, }, account: { accountLinking: { enabled: true, trustedProviders: ['google'] }, }, plugins: [ admin(), twoFactor({ issuer: 'my-app' }), passkey({ rpID: 'localhost', rpName: 'My App', origin: 'http://localhost:3000' }), organization({ teams: { enabled: true } }), nextCookies(), // keep last ], } satisfies BetterAuthOptions export const payloadAuthOptions = { hidePluginCollections: true, collectionAdminGroup: 'Auth', users: { slug: 'users', roles: ['user', 'editor', 'admin'], adminRoles: ['admin'], defaultRole: 'user', defaultAdminRole: 'admin', allowedFields: ['name', 'image'], }, adminInvitations: { async sendInviteEmail({ payload, email, url }) { await payload.sendEmail({ to: email, subject: 'You are invited', html: url }) return { success: true } }, }, betterAuthOptions, } satisfies PayloadAuthOptions export type PayloadAuthConfig = typeof payloadAuthOptions ``` Using `satisfies` rather than `:` preserves the literal types, which is what makes `getPayloadAuth()` infer your plugin set and role union correctly.