WABBIT · ESSAYS
Better Auth inside Payload: OAuth, account linking, roles, sessions
Payload's built-in auth is a good admin login and a poor front door. The moment a site needs Google sign-in, passkeys or a second factor, one common answer is Better Auth, and the work is in finding out how it fits next to Payload's users collection. Here's the fit, with the six places it bites: providers, linking, roles, sessions, the sign-up endpoint and the upgrade that returned {"headers":{}} to every auth route.
- Tested on
- Payload 3.92, Better Auth 1.7.15, @delmaredigital/payload-better-auth 0.14.1 (see step 1 for version floors), Next.js 16 App Router, MongoDB
- Applies to
- any Payload 3 + Next.js site that needs OAuth, passkeys or two-factor without giving up the admin panel
- Steps
- 12, about 33 min read
- Files you'll touch
- an options file, a plugins file, the users collection and a 40-line auth strategy, an access file, two small Better Auth plugins for the linking variant, a route gate with its verifier and test, a probe script, a seed and its scope helper
Payload ships its own authentication: an email and password login, a JWT in a cookie, lockout after failed attempts, API keys. For an admin panel with a handful of editors it's enough. It has none of the things a public-facing login needs built in: "Sign in with Google", passkeys, two-factor codes, linking a second provider to an existing account. Payload does give you a seam for them, custom auth strategies, and two community threads show what it costs to fill it. A thread asking for the cleanest Google Sign-In runs to 15 replies, goes through Clerk, a custom strategy and two plugins, and ends with the poster running Better Auth beside Payload without a plugin. A second 15-reply thread asks for a tutorial, gets a starter template and a Medium link, and leaves the question of whether customers belong in a separate collection unsettled.
Better Auth is the answer we settled on, and the plugin we use for it is @delmaredigital/payload-better-auth (the other maintained one is payload-auth, which the threads name more often). The plugin does two things: it turns Better Auth's data models into Payload collections, and it mounts Better Auth's HTTP handler as Payload endpoints. That's the whole trick, and it's also where the problems start, because now two systems have opinions about your users collection, your roles, your session cookie and your sign-up endpoint. The things that go wrong are specific. A provider that half-exists because one of its two environment variables is missing. A provider you meant for linking that also lets people sign in. A role field Better Auth wants as a string while Payload stores a relationship. A jwks collection that older adapter builds registered under one name and queried under another. A Better Auth upgrade (1.4 to 1.6) after which every /api/auth/* route answered 200 {"headers":{}} while the unit tests, the typecheck and the build all stayed green.
We've hit most of these on sites we run. This walkthrough is what we do now, rewritten for a generic Payload 3 + Next.js project. The code is written for this post and typechecked against a Payload 3.92 project on Better Auth 1.7.15 and adapter 0.14.1. Where we quote behavior, it's from the installed packages or their docs, and the sources are listed at the end.
Steps 1 to 3 put Better Auth next to Payload. Steps 4 to 7 cover OAuth and tokens: conditional providers, link-only providers, implicit sign-up and the jwks collection that breaks token signing at its defaults. Steps 8 and 9 are roles and sessions, which is where this meets Payload's access rules. Steps 10 to 12 are the operational part: a gate on the sign-up endpoint, a probe that catches a broken handler, and seeding users without hand-writing password hashes.
Step 1: Know what the adapter does, and what stays Payload's
Better Auth is a library with its own router, its own session store and its own data models: user, session, account, verification, plus whatever each plugin adds (passkey, twoFactor, jwks; organization adds several). It needs a database adapter to persist them. @delmaredigital/payload-better-auth provides that adapter, with Payload itself as the database: every Better Auth write becomes a payload.create or payload.update, so the rows show up in the admin and go through Payload's hooks.
The package is two Payload plugins and one auth strategy.
betterAuthCollections() reads the Better Auth schema from your options and generates a Payload collection for each model. Slugs are the model name with an s added, unless it already ends in one: sessions, accounts, verifications, passkeys, twoFactors. The user model is skipped by default, because you own that collection; the plugin adds any fields Better Auth needs that you haven't declared.
createBetterAuthPlugin() registers five custom endpoints on the Payload config, one per HTTP method, at /api/auth/:path*. Each one proxies the request to auth.handler. The Better Auth instance itself is created inside onInit, after Payload is ready, and attached as payload.betterAuth. Payload's REST catch-all route serves those endpoints, which matters in step 10. The plugin also notices a users collection with disableLocalStrategy and swaps in its own admin login view.
betterAuthStrategy() is the bridge back. It's a Payload custom auth strategy: on every request it asks Better Auth for the session, loads the matching user document and returns it as req.user. That is what makes your existing access rules work unchanged, with one catch that step 3 fixes: it loads the user at depth 0, so a roles relationship arrives as bare ids. If you followed our access control walkthrough, every staff check in it keeps working once that's handled, because req.user is the same document it always was.
What stays Payload's: the users collection and its fields, every access rule, the admin panel. What moves to Better Auth: passwords (the hash lives in accounts, under a credential provider row, and never on the user), sessions, OAuth, passkeys, two-factor, email verification and password reset. Keep that split in your head; most of the bugs below come from one side assuming the other's job.
A note on versions. Everything here was checked against adapter 0.14.1, the current release as we write. That release needs Node 22.23.4 or later, Next 16.4.1 or later, Better Auth 1.7.7 or later and Payload 3.90 or later, and those floors exist for security fixes, so don't work around them. The package moves quickly: 0.13.2 added a roleField option that names the user property holding roles. Nothing in this walkthrough depends on it, but read the changelog for your version before copying the plugins file.
Step 2: One options function, two plugins
The collections plugin and the main plugin both need the Better Auth options, and they need the same ones. The collections plugin derives the schema from plugins and user.additionalFields; the main plugin builds the runtime from the same keys. If they drift, you get a collection with the wrong fields and writes that fail at runtime. So build the options once:
One function feeds both plugins, so the schema and the runtime can't disagree. isSeedRun is step 12.
import type { BetterAuthOptions } from 'better-auth'
import { sendMail } from './mail'
import { isSeedRun } from './provisioning'
type Social = NonNullable<
BetterAuthOptions['socialProviders']
>
const env = process.env
// Both variables, or no provider. Better Auth
// only warns about a missing clientId; a missing
// secret fails at the first token exchange.
export function credentials(name: string) {
const clientId = env[`${name}_CLIENT_ID`]
const clientSecret = env[`${name}_CLIENT_SECRET`]
if (!clientId || !clientSecret) return null
return { clientId, clientSecret }
}
export function socialProviders(): Social {
const social: Social = {}
const google = credentials('GOOGLE')
if (google) social.google = google
const github = credentials('GITHUB')
if (github) social.github = github
return social
}
export function authOptions():
Partial<BetterAuthOptions> {
return {
emailAndPassword: {
enabled: true,
requireEmailVerification: true,
},
emailVerification: {
sendOnSignUp: true,
sendOnSignIn: true,
autoSignInAfterVerification: true,
sendVerificationEmail: async (args) => {
// Seeded accounts are marked verified by
// the seed; don't mail their addresses.
if (isSeedRun()) return
const { user, url } = args
await sendMail(user.email, 'Confirm', url)
},
},
socialProviders: socialProviders(),
account: {
accountLinking: {
enabled: true,
// A provider is attached to an existing
// account only when the signed-in user
// links it. See step 5 for the trade.
disableImplicitLinking: true,
},
},
session: {
expiresIn: 60 * 60 * 24 * 7,
updateAge: 60 * 60 * 24,
cookieCache: { enabled: false },
},
plugins: [],
}
}Steps 4 to 7 explain each block. Two things to settle now. requireEmailVerification: true is the right default for a public site, and on its own it's a trap: with no sendVerificationEmail, Better Auth sends nothing, sign-in throws EMAIL_NOT_VERIFIED, and nobody can ever verify. Wire the sender before you turn it on. sendOnSignIn: true re-sends the link whenever an unverified account tries to log in, which gives you a "resend" path with no extra UI.
Now the plugins. The role enrichment in the middle is explained in step 8; the rest is the plugin pair with the options function feeding both:
The collections plugin first, so its collections exist before the main plugin and your other plugins run.
import type { BasePayload, Plugin } from 'payload'
import { betterAuth } from 'better-auth'
import type { BetterAuthPlugin } from 'better-auth'
import { customSession } from 'better-auth/plugins'
import {
betterAuthCollections,
createBetterAuthPlugin,
payloadAdapter,
} from '@delmaredigital/payload-better-auth'
import { authOptions } from '@/lib/auth/options'
import {
ADMIN_PANEL_ROLES,
roleSlugs,
} from '@/access/roles'
const options = authOptions()
// The login view checks `user.role` on the session.
// Put the Payload role slugs there, so it agrees
// with Users.access.admin. This runs a depth-1
// user read on every session fetch.
const rolesOnSession = (payload: BasePayload) =>
customSession(async ({ user, session }) => {
const doc = await payload.findByID({
collection: 'users',
id: user.id,
depth: 1,
overrideAccess: true,
})
return {
user: { ...user, role: roleSlugs(doc) },
session,
}
})
export const authPlugins: Plugin[] = [
betterAuthCollections({
betterAuthOptions: options,
skipCollections: ['user'],
// Roles are a relationship here, so the
// plugin's `role` string hook is off. It
// warns once at boot; `access.create` and the
// roles field rule carry the weight instead.
firstUserAdmin: false,
}),
createBetterAuthPlugin({
createAuth: (payload) =>
betterAuth({
...options,
baseURL: process.env.SERVER_URL,
secret: process.env.BETTER_AUTH_SECRET,
database: payloadAdapter({
payloadClient: payload,
adapterConfig: { idType: 'text' },
}),
plugins: [
...(options.plugins ?? []),
rolesOnSession(payload) as unknown as
BetterAuthPlugin,
],
}),
admin: {
betterAuthOptions: options,
login: { requiredRole: [...ADMIN_PANEL_ROLES] },
},
}),
]Spread authPlugins at the front of your plugins array in payload.config.ts. Order matters twice: the collections plugin has to run before the main plugin, and both should run before plugins that expect the users collection to be finished.
On ids: the adapter detects MongoDB and uses text ids on its own, whatever you pass; the strategy in step 3 is separate code and defaults to 'number', so set it to 'text' there. On Postgres with serial ids, leave both at 'number' and add advanced: { database: { generateId: 'serial' } } to the options, or the adapter warns at boot and relationship writes fail validation because the ids arrive as strings.
On the secret: Better Auth 1.7 refuses to boot in production with the default secret, and refuses everywhere with none, so you'll find out early. What you won't be told is the second consequence: the jwt plugin encrypts its private signing key with that secret, so rotating the secret after keys exist makes every stored key undecryptable. Plan key rotation together with secret rotation.
Step 3: Point the users collection at Better Auth
Your users collection keeps its fields and rules. Two settings change: disableLocalStrategy turns off Payload's own password login, and strategies adds Better Auth's. The adapter fills in any Better Auth fields you leave out, so declare only the ones you want to control.
The strategy is the one piece we don't take from the adapter. Its betterAuthStrategy() loads the user at depth 0, which is fine when your roles are a string field and wrong when they're a relationship: req.user.roles is then an array of ids, every slug check sees no roles, and the admin gate below admits nobody. Depth is the only reason to replace it on a current adapter: since 0.13.0 it handles Better Auth's refreshed cookie the way step 9 describes, and earlier releases never handed it back to Payload at all. A strategy of your own takes about forty lines:
The adapter's flow at depth 1. The refreshed cookie is forwarded when the request can set headers; otherwise the read doesn't refresh.
import type { AuthStrategy } from 'payload'
type Session = { user?: { id?: string } } | null
type GetSession = (args: {
headers: Headers
returnHeaders: true
query?: { disableRefresh: boolean }
}) => Promise<{ headers: Headers; response: Session }>
type WithAuth = {
betterAuth?: { api: { getSession: GetSession } }
}
// Same flow as the adapter's strategy, except the
// user loads at depth 1 so `roles` is populated
// for access rules. Better Auth's refreshed cookie
// goes back to Payload when the request can set
// headers; if it can't, the read doesn't refresh.
export const betterAuthSession: AuthStrategy = {
name: 'better-auth',
authenticate: async ({
payload,
headers,
canSetHeaders,
}) => {
const auth = (payload as unknown as WithAuth)
.betterAuth
if (!auth) return { user: null }
try {
const result = await auth.api.getSession({
headers,
returnHeaders: true,
query: canSetHeaders
? undefined
: { disableRefresh: true },
})
const id = result.response?.user?.id
if (!id) return { user: null }
const doc = await payload.findByID({
collection: 'users',
id,
depth: 1,
overrideAccess: true,
})
const collection = 'users' as const
return {
user: { ...doc, collection },
responseHeaders: canSetHeaders
? result.headers
: undefined,
}
} catch {
return { user: null }
}
},
}returnHeaders: true makes Better Auth's server API return { headers, response } instead of the bare session, and Payload merges a strategy's responseHeaders into the request so the REST route writes them. disableRefresh stops a read that can't deliver a cookie from moving the session row, which step 9 explains. The other way to handle depth 0 is to keep the adapter's strategy and make your role check resolve ids against the roles collection on each call; it works, and it costs a query per check instead of one per request. If you use the adapter's strategy with a role string field, none of this applies.
Then the collection:
disableLocalStrategy hands passwords to Better Auth; the strategy gives the session back to Payload as req.user.
import type {
Access,
CollectionConfig,
} from 'payload'
import {
betterAuthSession,
} from '@/lib/auth/strategy'
import { firstAdmin } from './Users/firstAdmin'
import {
ADMIN_PANEL_ROLES,
STAFF_ROLES,
hasRole,
staff,
} from '@/access/roles'
const isStaff = hasRole(STAFF_ROLES)
const staffOrSelf: Access = ({ req }) => {
if (!req.user) return false
if (isStaff(req.user)) return true
return { id: { equals: req.user.id } }
}
export const Users: CollectionConfig = {
slug: 'users',
hooks: { beforeValidate: [firstAdmin] },
auth: {
// Better Auth owns passwords and sessions.
disableLocalStrategy: true,
strategies: [betterAuthSession],
},
access: {
admin: ({ req }) =>
hasRole(ADMIN_PANEL_ROLES)(req.user),
create: staff,
read: staffOrSelf,
update: staffOrSelf,
delete: staff,
},
fields: [
{ name: 'email', type: 'email', required: true },
{ name: 'emailVerified', type: 'checkbox' },
{ name: 'name', type: 'text' },
{ name: 'image', type: 'text' },
{
name: 'roles',
type: 'relationship',
relationTo: 'roles',
hasMany: true,
access: {
update: ({ req }) => isStaff(req.user),
},
},
],
}read: staffOrSelf rather than "any signed-in user" is deliberate. With public sign-up, a session isn't a role, and GET /api/users with a plain authenticated rule lists every customer's email to every other customer. The roles field's update rule stops a member from editing their own roles through the self-update path; step 8 covers what fills it.
Some sites keep Payload's local strategy next to Better Auth's, as a fallback if Better Auth is ever down. That path needs a beforeValidate hook that gives each new user a throwaway password, because Payload still requires one on create, and the fallback only works for accounts whose Payload password was later set by hand. The mainstream path is disableLocalStrategy: true with a break-glass admin created through Better Auth, and it's what the rest of this walkthrough assumes.
Step 4: Providers that exist only when configured
Better Auth registers a social provider whether or not its credentials are complete. It warns at boot only when clientId is missing; a missing clientSecret is silent until the first token exchange fails, which means a user clicks the button and gets an error page. The credentials() helper in step 2 reads both variables and returns nothing unless both are set, so a local checkout with no Google keys has no Google provider at all, and a production deploy that forgot one variable ships without the button instead of with a broken one. Whether a half-configured provider's button renders is up to your login page; the helper makes the question moot.
The pattern generalizes: read the environment once, build the socialProviders object from what's there, and give the front end a list of which providers exist so it renders the right buttons. We derive that list from the same object (Object.keys(socialProviders())) rather than from a second set of flags, so there's one place that decides.
Two provider options turn up in step 5's variant and deserve a line each. prompt: 'consent' forces the provider's consent screen on every authorization. Discord's bundled provider defaults the OAuth prompt parameter to none, which tells Discord to suppress interactive UI; the first time a member links, or whenever you add a scope, the flow fails with no screen to fail on. disableImplicitSignUp is step 6.
Step 5: A provider for linking, not for signing in
Account linking is on by default in Better Auth. When someone signs in with a provider whose email matches an existing user, and the provider says the email is verified, Better Auth attaches the provider's identity to that user. Since 1.6.11 it also requires the local account's own email to be verified (requireLocalEmailVerified, default on). The accountLinking block tunes it, and three of its keys change the security of your site.
trustedProviders lists providers whose identity gets linked even when the provider doesn't confirm the email as verified. The docs say to use it with caution because it may increase the risk of account takeover, and for Google it buys nothing, since Google reports email_verified; it's for providers that never report verification. allowDifferentEmails lets a user link a provider account whose email differs from their own. It's needed for a provider that does not return email addresses, and it's needed when members use a different address at the provider than on your site. The switch is global: once on, it's on for every provider. disableImplicitLinking turns automatic linking off entirely; a sign-in that matches an existing email is refused with account_not_linked, and users attach providers explicitly through linkSocial() while signed in.
That last one is the step 2 default, and it's our first recommendation. Explicit linking means a provider is attached to an account only by the person holding a session on that account, with the fresh-sign-in guard from step 9 behind it. The cost is UX: someone who signed up with email can't "Continue with Google" until they've linked Google from their account page, and the login form has to say so. For a site where that's acceptable, you're done with this step. The history is why we take the trade. Before 1.6.11, Better Auth checked the provider's verified claim and never checked whether the local account's email was verified. An attacker could pre-register the victim's email with a password, creating an unverified local account; when the victim later signed in with Google, the identity was linked to the attacker's account, and requireEmailVerification didn't help because the link marked the account verified. That's advisory GHSA-g38m-r43w-p2q7, patched in 1.6.11 with the requireLocalEmailVerified gate above. If you're on an older line, upgrade before you turn linking on.
Now the case this step is named for. Say a site lets members connect a Discord account to their profile but shouldn't accept Discord as a login. Discord accounts get phished, so "Sign in with Discord" would let a hijacked Discord account walk past the site password. Members' Discord emails also often differ from their site emails. So Discord needs: linking allowed, different emails allowed, sign-in refused. If Google on the same site keeps implicit linking as a sign-in provider, a Google identity linked under a different email would be a standing login into that account, and it needs the opposite rule.
Better Auth has one global allowDifferentEmails, so the per-provider rule goes in a hook. First, refuse social sign-in for the link-only provider, at the endpoint, so a direct POST can't bypass a removed button:
A plugin. The sign-in endpoint is refused; /link-social and the callback are untouched.
import type { BetterAuthPlugin } from 'better-auth'
import {
APIError,
createAuthMiddleware,
} from 'better-auth/api'
// Refuses "sign in with <provider>" at the
// endpoint. Linking goes through /link-social
// and the callback, which this never touches.
export const linkOnly = (
provider: string,
): BetterAuthPlugin => ({
id: `link-only-${provider}`,
hooks: {
before: [
{
matcher: (ctx) =>
ctx.path === '/sign-in/social',
handler: createAuthMiddleware(async (ctx) => {
const body = ctx.body as
| { provider?: unknown }
| undefined
if (body?.provider !== provider) return
throw new APIError('FORBIDDEN', {
message:
`Sign in with ${provider} is off. ` +
'Use your email and password.',
})
}),
},
],
},
})It's written as a plugin on purpose. Better Auth also accepts a top-level hooks.before, and the docs steer you to a plugin when a hook is reused. There's a stronger reason in step 11, where payload-auth, a different Payload adapter, wrapped hooks.before and took every auth route down. @delmaredigital doesn't touch that option, so it's safe on this stack; a plugin is still the cleaner home.
Second, require a matching email for the providers that can sign in, so allowDifferentEmails: true for Discord doesn't open Google:
Runs before the accounts row is written. Returning false rejects the link.
import type { BetterAuthOptions } from 'better-auth'
type Hooks = NonNullable<
BetterAuthOptions['databaseHooks']
>
// Providers that can sign a user in. A link from
// one of these must carry the account's own email.
const SIGN_IN_PROVIDERS = ['google']
function idTokenEmail(token: unknown) {
if (typeof token !== 'string') return null
const part = token.split('.')[1]
if (!part) return null
try {
const claims = JSON.parse(
Buffer.from(part, 'base64url').toString('utf8'),
) as { email?: unknown }
return typeof claims.email === 'string'
? claims.email.toLowerCase()
: null
} catch {
return null
}
}
export const linkHooks: Hooks = {
account: {
create: {
before: async (account, ctx) => {
const { providerId, userId } = account
if (!SIGN_IN_PROVIDERS.includes(providerId)) {
return undefined
}
const db = ctx?.context.internalAdapter
const user = await db?.findUserById(userId)
// Better Auth writes the user before its
// account row, so this is a defensive no-op.
if (!user) return undefined
const email = idTokenEmail(account.idToken)
// `false` rejects the row; Better Auth then
// refuses the link.
return email === user.email.toLowerCase()
? undefined
: false
},
},
},
}Decoding an ID token without verifying it is not something to do in general. It's sound in this one place because the hook runs on data Better Auth just received from the provider's token endpoint in the same request, and Better Auth's own Google provider reads the user's email from the same decode, so the hook sees what Better Auth saw. A Google account row with no ID token isn't a normal flow and is rejected.
Then the variant itself, layered over the step 2 options:
Wrap authOptions() with this only if you have a link-only provider. It loosens allowDifferentEmails globally and tightens Google back with the hook.
import type { BetterAuthOptions } from 'better-auth'
import { linkHooks } from './linkEmailPolicy'
import { linkOnly } from './linkOnly'
import { credentials } from './options'
// The link-only variant: Discord may be attached
// to an account under any email, may not create
// an account, and may not sign one in. Google
// keeps implicit linking, with its email checked.
export function withLinkOnlyDiscord(
base: Partial<BetterAuthOptions>,
): Partial<BetterAuthOptions> {
const discord = credentials('DISCORD')
return {
...base,
socialProviders: {
...base.socialProviders,
...(discord
? {
discord: {
...discord,
disableImplicitSignUp: true,
prompt: 'consent',
},
}
: {}),
},
account: {
accountLinking: {
enabled: true,
allowDifferentEmails: true,
},
},
databaseHooks: linkHooks,
plugins: [
...(base.plugins ?? []),
linkOnly('discord'),
],
}
}Keep the two halves together in your head: allowDifferentEmails loosens linking globally, and the hooks tighten it back per provider. If you don't have a link-only provider, you don't need the variant, and the step 2 default with explicit linking is the safer configuration.
Step 6: Stop implicit sign-up
A social sign-in creates a new user when no account matches. That's what you want on a consumer site and what you don't want on a members-only one, where accounts come from an application, an invite or a purchase. Better Auth has two per-provider switches. disableImplicitSignUp: true means a plain sign-in won't create a user; the client has to call sign-in with requestSignUp: true to do that, so your own sign-up page can still offer "Continue with Google" while a stray sign-in can't mint an account. disableSignUp: true means the provider never creates a user. For email and password there's emailAndPassword.disableSignUp.
On a site that's live but not open yet, there's a third shape: everything exists, and sign-up has to answer 403 until launch day. A flag that only hides the sign-up form doesn't close sign-up: a direct POST /api/auth/sign-up/email still creates an account, and that account can then write through Payload's REST. Close the three paths that create an account, at the route that serves them: sign-up/*, sign-in/social (a provider's first login) and callback/* (where an OAuth sign-up completes). Leave email sign-in open so staff can still log in. Step 10 shows where that gate lives and why it's a route and not a Better Auth option.
Step 7: The collection that pluralizes twice
If you add Better Auth's jwt plugin, which signs JSON Web Tokens for an OAuth provider or an MCP server, it brings a model named jwks. The Payload collections plugin adds an s to a model name only when it doesn't already end in one, so it registers a collection called jwks. Better Auth's own adapter factory, which every runtime query goes through, appends the s regardless and asks the Payload adapter for jwkss. Before adapter 0.14.1, the adapter's guard meant to strip the extra s never fired, because the factory had already pluralized the name by the time it arrived, and the guard only fired when the incoming name didn't end in ss. On those versions, with the plugin at its defaults, the first token request fails: "The collection with slug jwkss can't be found."
We reproduced that on Better Auth 1.7.5 with adapter 0.10.0, and on adapter 0.14.0 with Better Auth 1.7.13, and reported it upstream. The maintainer fixed it in 0.14.1, so on that release or later jwt() works at its defaults and you can skip the rename. On an older adapter, give the model a singular name, so both sides arrive at jwks without any normalization:
Required with the jwt plugin before adapter 0.14.1. Both sides then resolve the model to the same jwks collection.
import { jwt } from 'better-auth/plugins'
export const signedTokens = () =>
jwt({ schema: { jwks: { modelName: 'jwk' } } })With the rename, the collections plugin pluralizes jwk to jwks and the runtime asks for jwk plus s, which is also jwks. The collection slug doesn't change, so adding the rename to a site that already has keys keeps them. In our test, the same database failed twice with the default config, then signed a token and published its key on the first run with the rename. The rename keeps working after you upgrade to 0.14.1, and you can remove it then. If you hand-registered a jwkss collection to get past the error, rename its slug to jwks when you upgrade.
Because the collections plugin and the runtime both read the options function from step 2, the override reaches both. If you build the options in two places, this is the kind of key that drifts: the collection gets registered under one name and queried under another, and nothing fails until the first signing request.
Step 8: Roles: pick one source of truth
Better Auth's admin plugin, the adapter's helpers and the adapter's login view all expect a role field on the user: a string, or an array of strings, and the helpers refuse to split a comma-separated one because "super,admin" would match admin by accident. Payload projects usually store roles as a relationship to a roles collection, with permissions on the role. Those two models don't meet on their own, and the failure is quiet: the login view's requiredRole check, which defaults to 'admin', inspects user.role on the session, finds nothing, and shows "Access Denied" to a user your access.admin rule would have let in.
There are two sane arrangements. Store roles as a plain role field, use the adapter's isAdmin({ adminRoles }) helpers, and let its first-user-admin hook assign the bootstrap role; that's the shortest path for a small site. Or keep the relationship and teach the session about it, which is what the rolesOnSession plugin in step 2 does: on every /get-session, it loads the user at depth 1 and puts the role slugs on user.role. The login view then sees the same slugs your access rules see. That's the arrangement we use, because permissions live on the role documents and editors manage them in the admin. The cost is one depth-1 user read per session fetch, on top of the strategy's own read.
Whichever you pick, the admin panel has two gates, and they must read the same list. Users.access.admin is Payload's: it decides whether the panel serves you. admin.login.requiredRole on the plugin is the login view's: it runs after you authenticate and before it routes you to /admin. Widen one without the other and you get a user who logs in successfully and is then refused. A read-only demo role is the usual way to hit this: Payload's gate admits it and the login view's default list doesn't. The fix is one constant both gates read. Here it is, with the helpers the collection and the plugin import:
One list for both admin gates. roleSlugs needs the relationship populated, which is why step 3's strategy loads at depth 1.
import type { Access } from 'payload'
export const STAFF_ROLES = [
'super-admin',
'admin',
'editor',
] as const
export const VIEWER_ROLE = 'viewer'
// Who may open /admin. Both gates read this list.
export const ADMIN_PANEL_ROLES = [
...STAFF_ROLES,
VIEWER_ROLE,
] as const
// Slugs off a user whose `roles` relationship is
// populated (depth 1). A bare id is not a role,
// so an unpopulated user fails closed.
export function roleSlugs(user: unknown): string[] {
if (!user || typeof user !== 'object') return []
const roles = (user as { roles?: unknown }).roles
if (!Array.isArray(roles)) return []
return roles.flatMap((r) =>
r && typeof r === 'object' && 'slug' in r &&
typeof r.slug === 'string'
? [r.slug]
: [],
)
}
export const hasRole =
(allowed: readonly string[]) =>
(user: unknown): boolean =>
roleSlugs(user).some((s) => allowed.includes(s))
export const staff: Access = ({ req }) =>
hasRole(STAFF_ROLES)(req.user)
// Read-only demo accounts may read seed-owned
// collections. Never use this on anything that
// can hold a visitor's data.
export const staffOrViewer: Access = ({ req }) =>
hasRole(ADMIN_PANEL_ROLES)(req.user)Three details. roleSlugs fails closed on an unpopulated relationship. The adapter's strategy loads at depth 0, so either use the step 3 strategy, which loads at depth 1, or make hasRole resolve ids against the roles collection. requiredRole: null on the plugin disables the login view's check entirely, leaving Payload's gate as the only one; if you go that way, pass null, never undefined, because undefined falls back to the default parameter, 'admin', and the check comes back on with a slug you may never issue. And the adapter's firstUserAdmin hook writes a role string key on every user create and runs a count query first; on a relationship model that key has no field and is dropped, while the hook still runs, and on 0.14.1 a second hook logs a QueryError at the first sign-up. Pass firstUserAdmin: false, as the step 2 plugins file does, deal with the one-line boot warning (once access.create rejects non-admins, acknowledgeRoleGuardDisabled: true silences it), and carry the weight yourself: access.create is staff, the roles field's update is staff, and your own beforeValidate gives the first user the top role, everyone else the frontend role, with nothing in the request body able to change that.
One more role shape to know about. Better Auth's admin plugin, which adds user banning and impersonation, reads user.role straight from the database in its impersonation endpoint and calls .split(',') on it, still true at 1.7.5. If your adapter stores that field as an array, impersonation throws. One fix is to override the injected field to text with a hook that joins arrays on write and on read. If you don't use impersonation, you won't hit it.
Who assigns roles at sign-up, when the hook is on, is worth knowing too. It makes the first user an admin and gives everyone after that the default role, and it ignores any role a client sends on the sign-up path, because that body is attacker-controlled. With the hook off, the plugin warns you that POST /api/users can now seed a row with whatever role the caller chooses. That's the warning the step 2 config accepts, and the two rules above are the answer to it.
Bootstrap the first admin yourself
With the hook off, nothing makes anyone an admin, and Payload's "create first user" screen is gone because Better Auth owns sign-in. A fresh install where everyone signs up as a plain user is a site nobody can administer. So the users collection assigns the first admin itself, through the same roles relationship every other rule reads.
Two details decide whether that works. Count people, not rows: plugins and layers often create system accounts at boot (a guest user for checkout, a fixture for tests), and a rule that asks "is the collection empty?" never sees zero, so the first real sign-up silently becomes a customer. Give every account the site creates for itself an address on a reserved domain, such as @system.invalid, and leave those out of the count. And on a site that's public from its first deploy, "first person to sign up" means whoever gets there first, so in production name the address that's allowed to become the first admin:
Gives the top role to the first person, or only to BOOTSTRAP_ADMIN_EMAIL when it's set. System accounts never count.
import type {
CollectionBeforeValidateHook,
PayloadRequest,
} from 'payload'
// Accounts the site makes for itself (guest
// checkout, fixtures) use this reserved domain.
const SYSTEM = '@system.invalid'
const TOP_ROLE = 'super-admin'
async function topRoleId(req: PayloadRequest) {
const found = await req.payload.find({
collection: 'roles',
where: { slug: { equals: TOP_ROLE } },
limit: 1,
req,
})
if (found.docs[0]) return found.docs[0].id
const role = await req.payload.create({
collection: 'roles',
data: {
name: 'Super admin',
slug: TOP_ROLE,
accessType: 'admin',
permissions: { canAccessAdminPanel: true },
},
req,
})
return role.id
}
// Who becomes the first admin. With
// BOOTSTRAP_ADMIN_EMAIL set (do this in
// production), that address gets the top role as
// long as nobody holds it yet. Without it, the
// first person to sign up does.
export const firstAdmin:
CollectionBeforeValidateHook = async ({
data,
operation,
req,
}) => {
if (operation !== 'create' || !data) return data
if (data.roles?.length) return data
const email = String(data.email ?? '')
.toLowerCase()
if (email.endsWith(SYSTEM)) return data
const want = process.env.BOOTSTRAP_ADMIN_EMAIL
if (want) {
if (email !== want.toLowerCase()) return data
const top = await topRoleId(req)
const holders = await req.payload.count({
collection: 'users',
where: { roles: { in: [top] } },
req,
})
if (holders.totalDocs > 0) return data
return { ...data, roles: [top] }
}
const people = await req.payload.count({
collection: 'users',
where: { email: { not_like: SYSTEM } },
req,
})
if (people.totalDocs > 0) return data
return { ...data, roles: [await topRoleId(req)] }
}The hook runs on every create, Better Auth's sign-up included, because the adapter writes users through Payload. It only fills roles when the incoming data has none, so an admin assigning roles in the dashboard is untouched, and a sign-up can't send its own roles past it: Better Auth drops fields it doesn't know before the write. Two sign-ups landing in the same instant could both pass the "nobody yet" check; on a new site that's a narrow window, and the bootstrap address closes it in production.
Step 9: Sessions: the cookie, the server and payload.auth
Better Auth sessions are server-side rows in the sessions collection, referenced by a token in an HttpOnly cookie named better-auth.session_token (with a __Secure- prefix over HTTPS). Payload 3's own JWT is session-backed too by default; the difference is that Better Auth's row is the only thing the cookie references, so revocation is one delete, with no token to invalidate.
Three numbers shape a session. expiresIn is the idle lifetime (7 days by default). updateAge is how often an active session has its expiry pushed forward (1 day). freshAge is how recent a sign-in must be for the operations Better Auth guards with it, which at 1.7.5 are unlinking a provider and deleting the account without re-entering the password (1 day; 0 disables the check). Password and email changes are guarded differently: the current password for one, a sensitive-session check for the other. Leave the fresh-age guard on.
cookieCache is the one to decide on purpose. When it's enabled, Better Auth stores a signed copy of the session in a second short-lived cookie and skips the database read on most requests. The cost is that a revoked session stays valid on other devices until that cookie's maxAge passes, because the server can't delete cookies it doesn't hold. A member site might run a 7-day sliding window with the cache off, so a revoked session dies on the next request, and pay one database read per request for it. A store might run 30 days with a five-minute cache and accept that a revoked session lives up to five minutes on other devices.
Reading the session on the server has two forms. The direct one is auth.api.getSession({ headers }), which the adapter exposes as payload.betterAuth.api.getSession. The one we reach for first is Payload's own:
payload.auth runs every strategy on the users collection and returns the same user your access rules see.
import { headers } from 'next/headers'
import { getPayload } from 'payload'
import config from '@payload-config'
import type { User } from '@/payload-types'
// Runs every strategy on the users collection,
// Better Auth's included, and returns the same
// user that access rules will see.
export async function currentUser():
Promise<User | null> {
const payload = await getPayload({ config })
const { user } = await payload.auth({
headers: await headers(),
canSetHeaders: false,
})
if (!user) return null
if (user.collection !== 'users') return null
return user
}payload.auth({ headers }) runs the strategies on your auth collection, so with the step 3 strategy installed it resolves a Better Auth cookie to a Payload user document with roles populated. That's the user to pass to Local API calls that act for a visitor, together with overrideAccess: false, which the access control walkthrough covers in detail. canSetHeaders: false tells Payload, and the strategy, not to try to write cookies from a place that can't.
That last point has a consequence for sliding sessions. Better Auth refreshes the session row when updateAge passes, and it hands back a refreshed cookie for the caller to set. A Server Component can't set it, so a refresh there would move the row and leave the cookie behind. The step 3 strategy forwards the headers when Payload says it can set them, which covers route handlers and server actions, and otherwise tells Better Auth not to refresh, so the row and the cookie stay together. The adapter's own strategy has done the same since 0.13.0; before that it never forwarded the header at all. The catch is that a member who only ever loads pages never slides, and gets logged out a fixed expiresIn after sign-in. For page loads, the fix is a small client component that calls GET /api/auth/get-session on an interval, which runs through Better Auth's handler and can set cookies. That only works if nothing rendered on the server has already used up the refresh, so any direct auth.api.getSession in a Server Component needs query: { disableRefresh: true } too. Otherwise the render moves the row, the row is no longer due, and the client's call gets no new cookie.
If you're coming from Payload's own cookie auth, one more thing changes for the better. Payload's JWT extraction has had a long-running problem with cross-site navigations: clicking a link from an email into an authenticated route could log the user out, and OAuth callbacks from some providers hit the same check (issue #16031, still open on the current 3.x at the time of writing). Better Auth's cookie goes through Better Auth's handler, so that check never sees it.
Step 10: Gate the sign-up endpoint where it's actually mounted
Public sign-up gets farmed. A form with no captcha, honeypot or rate limit collects bot accounts with gibberish names over harvested real addresses, a few a day. Better Auth's default limiter (100 requests per 10-second window per IP, and 3 per 10 seconds on sign-up and sign-in, production only) is tuned for bursts, not for a few patient sign-ups a day. Turnstile on the sign-up form is the fix, and the question is where the check goes.
Here's the part that trips people up. With the adapter, Better Auth has no route file of its own. Its handler is mounted as Payload custom endpoints, and the file that serves those is Payload's REST catch-all, src/app/(payload)/api/[...slug]/route.ts, the one with the "do not modify" header. That file is where a request to /api/auth/sign-up/email first becomes code you own, so that's where the gate lives. A Better Auth hooks.before on /sign-up/email is the obvious alternative and the wrong one: auth.api.signUpEmail(), which your seed scripts and your guest-checkout provisioning call in-process, runs the same middleware, so the hook would demand a Turnstile token from code that has none. The route only sees HTTP, which is the boundary you want. One addition: if your sign-up page posts to a server action that calls signUpEmail itself, that action is a second browser-facing entry and needs the same check, because a curl to the REST path and a form post to the action are two doors into one room.
The gate is a wrapper around REST_POST. It matches the sign-up path, runs a verifier, and otherwise hands the request through untouched:
Pure: no Payload import, so a test can drive it with a Request.
type Params = { params: Promise<{ slug: string[] }> }
export type Handler = (
req: Request,
params: Params,
) => Promise<Response>
// Better Auth's email sign-up, as Payload mounts
// it: /api/auth/sign-up/email.
export const SIGNUP_PATH = [
'auth',
'sign-up',
'email',
]
export const isSignup = (slug: string[]) =>
slug.length === SIGNUP_PATH.length &&
SIGNUP_PATH.every((part, i) => slug[i] === part)
const REJECTED = {
message:
'We could not verify your submission. ' +
'Please refresh the page and try again.',
code: 'SIGNUP_REJECTED',
}
export const gateSignup =
(
next: Handler,
verify: (req: Request) => Promise<boolean>,
): Handler =>
async (req, params) => {
const { slug } = await params.params
if (isSignup(slug) && !(await verify(req))) {
return Response.json(REJECTED, { status: 403 })
}
return next(req, params)
}The verifier reads the Turnstile token from a header, so the JSON body stays exactly what Better Auth's client sends, and checks it with Cloudflare. For the client IP it takes the rightmost x-forwarded-for entry, the hop your own proxy added, for the reason the form builder tutorial gives in its rate-limit step. cf-connecting-ip is only trustworthy behind Cloudflare; drop that line if you aren't:
Unconfigured means open in development and closed in production. The action must match the widget's data-action. The IP is the rightmost x-forwarded-for entry.
const SITEVERIFY =
'https://challenges.cloudflare.com' +
'/turnstile/v0/siteverify'
const TOKEN_HEADER = 'cf-turnstile-response'
// The token travels in a header, so the JSON body
// stays exactly what Better Auth expects.
export async function verifySignup(req: Request) {
const secret = process.env.TURNSTILE_SECRET_KEY
// No secret: open in development, closed in
// production. A deploy that forgot the key
// should fail loudly, not quietly admit bots.
if (!secret) {
return process.env.NODE_ENV !== 'production'
}
const token = req.headers.get(TOKEN_HEADER)
if (!token) return false
// Your proxy appends the address it saw as the
// last x-forwarded-for entry. Anything to its
// left came from the client and can be forged.
const ip =
req.headers.get('cf-connecting-ip') ??
req.headers
.get('x-forwarded-for')
?.split(',')
.at(-1)
const res = await fetch(SITEVERIFY, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
secret,
response: token,
remoteip: ip?.trim(),
}),
})
const data = (await res.json()) as {
success?: boolean
action?: string
}
return (
data.success === true && data.action === 'signup'
)
}The generated file, with POST wrapped. Everything else is untouched.
import config from '@payload-config'
import {
REST_DELETE,
REST_GET,
REST_OPTIONS,
REST_PATCH,
REST_POST,
} from '@payloadcms/next/routes'
import { verifySignup } from '@/lib/turnstile'
import { gateSignup } from './gate'
export const GET = REST_GET(config)
export const POST = gateSignup(
REST_POST(config),
verifySignup,
)
export const DELETE = REST_DELETE(config)
export const PATCH = REST_PATCH(config)
export const OPTIONS = REST_OPTIONS(config)On the client, your sign-up form renders the Turnstile widget with data-action="signup" and sends the token in the cf-turnstile-response header on the sign-up call; Better Auth's client accepts a fetchOptions.headers object for that. You can run the cheap layers from the form builder tutorial in front of Turnstile: a honeypot field, a minimum fill time and a per-IP window.
Now the bug that makes the test below worth writing. A gate that matches on a slug literal can match nothing without anyone noticing: rename the collection, or move the forms to server actions, and the gate protects nothing while every test stays green. Import the slug from the module that owns it. The lesson is broader than slugs: a gate that matches nothing passes every test that doesn't drive the handler with a real request.
So drive the handler:
Node's built-in runner; run it with tsx or on Node 22.6+ with type stripping. The gate is called with the path the form posts to and a stubbed verifier.
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { gateSignup } from '../app/api/gate'
const passed = async () => new Response('passed')
// Drives the real handler with the real path the
// form posts to. A gate that matches nothing
// passes every other kind of test.
const run = (slug: string[], verdict: boolean) =>
gateSignup(passed, async () => verdict)(
new Request('http://x/api/' + slug.join('/'), {
method: 'POST',
}),
{ params: Promise.resolve({ slug }) },
)
test('a failed check refuses sign-up', async () => {
const res = await run(
['auth', 'sign-up', 'email'],
false,
)
assert.equal(res.status, 403)
})
test('a passed check lets it through', async () => {
const res = await run(
['auth', 'sign-up', 'email'],
true,
)
assert.equal(res.status, 200)
})
test('sign-in is never checked', async () => {
const res = await run(
['auth', 'sign-in', 'email'],
false,
)
assert.equal(res.status, 200)
})Point the import at wherever your gate.ts lives. It's a .ts file on Node's test runner, so run it through tsx (tsx --test tests/signup-gate.test.ts) or on Node 22.6 or later with --experimental-strip-types; the three tests need no database. If you also want the preview-mode gate from step 6, it's the same wrapper with a different matcher (sign-up/*, sign-in/social, callback/*) and a 403 while the flag is on, wrapped outermost so it answers before Turnstile or Payload run.
If you mount Better Auth yourself instead, through toNextJsHandler in an app/api/auth/[...all]/route.ts, the same gate goes in that file's POST, matching on pathname.endsWith('/sign-up/email'). The principle doesn't change: gate at the HTTP boundary you own, and leave in-process calls alone.
Step 11: A probe that catches {"headers":{}}
On payload-auth, the other Payload adapter, a Better Auth bump from the 1.4 line to 1.6 made every /api/auth/* route answer HTTP 200 with the body {"headers":{}}: sign-in, session, the /ok endpoint, passkeys. Unit tests passed, the typecheck passed, the production build passed. Only a real request through the handler shows it.
The cause was narrow and worth understanding. payload-auth wraps the consumer's hooks.before in its own middleware and returns the inner result. Somewhere between Better Auth 1.4.20, which worked, and 1.6.22, which didn't (most likely the request-handling rewrite in 1.6.15), the before-hook runner started calling hooks in a mode where the inner middleware returns a { headers, response } envelope; the outer wrapper wrapped it again, the runner saw a response object with no request context, and it sent that object as the reply to every request. That adapter's newer major did the same thing, so it wasn't a version pin problem. When the only hooks.before is a single sign-in block, moving it into a plugin (the linkOnly shape from step 5), which payload-auth doesn't wrap, brings the routes back. The rule: never a top-level hooks.before behind payload-auth. @delmaredigital never touches that option, so on the stack this walkthrough teaches the rule doesn't bite; the probe still earns its place, because "every route returns the same wrong body" is a shape of failure, not a property of one adapter, and nothing short of a request catches it.
The probe boots Payload without Next and drives the handler directly:
Boots Payload, so it refuses a non-local database by default. Fails on {"headers":{}} or any wrong status.
import { getPayload } from 'payload'
import config from '@payload-config'
type Result = { status: number; body: string }
type Check = [string, Result, (r: Result) => boolean]
type Handler = (r: Request) => Promise<Response>
type WithHandler = {
betterAuth?: { handler?: Handler }
}
// Must equal the auth baseURL (or be in
// trustedOrigins), or the POSTs fail the origin
// check instead of testing anything.
const ORIGIN = process.env.SERVER_URL ??
'http://localhost:3000'
const EMPTY = '{"headers":{}}'
async function main() {
// Booting Payload runs onInit, so refuse any
// database that isn't local unless told to.
const db = process.env.DATABASE_URI ?? ''
const local = /localhost|127\.0\.0\.1/.test(db)
const allowRemote = process.env.AUTH_PROBE_REMOTE
if (!local && allowRemote !== '1') {
console.error('auth probe: database is not local')
process.exit(2)
}
const payload = await getPayload({ config })
const auth = (payload as unknown as WithHandler)
.betterAuth
if (!auth?.handler) {
console.error('betterAuth.handler is missing')
process.exit(1)
}
const handler = auth.handler
const call = async (
path: string,
init?: RequestInit,
): Promise<Result> => {
const req = new Request(ORIGIN + path, init)
const res = await handler(req)
const text = await res.text()
const body = text.slice(0, 200).trim()
return { status: res.status, body }
}
const post = (body: unknown): RequestInit => ({
method: 'POST',
headers: {
'content-type': 'application/json',
origin: ORIGIN,
},
body: JSON.stringify(body),
})
const checks: Check[] = [
[
'ok',
await call('/api/auth/ok'),
(r) =>
r.status === 200 && r.body === '{"ok":true}',
],
[
'anonymous session',
await call('/api/auth/get-session'),
(r) => r.status === 200 && r.body === 'null',
],
[
'wrong password',
await call(
'/api/auth/sign-in/email',
post({
email: 'nobody@example.invalid',
password: 'wrong',
}),
),
(r) =>
r.status === 401 && r.body.includes('code'),
],
]
const failed = checks.filter(([name, res, ok]) => {
const bad = res.body === EMPTY || !ok(res)
console.log(bad ? 'FAIL' : 'ok ', name, res)
return bad
})
process.exit(failed.length > 0 ? 1 : 0)
}
main().catch((err) => {
console.error(err)
process.exit(1)
})Run it with whatever loads your TypeScript and path aliases (tsx works) and a .env pointed at a local database. ORIGIN has to be the auth baseURL or one of your trustedOrigins, or Better Auth's origin check rejects the POSTs before they test anything. Add it to the checklist for any change to better-auth, @better-auth/* or the adapter: the probe must pass, and then a real login on staging. Add a check for each plugin you rely on: a passkey challenge, an admin endpoint that must answer 401 anonymously, a refused link-only sign-in, and the client IP your rate limiter keys on.
Rename DATABASE_URI to whatever your project calls its connection string. The guard exists because booting Payload runs onInit, and a probe pointed at production would run your seeds.
Step 12: Seed users through Better Auth, and keep a read-only demo admin
Seeding an admin used to be payload.create({ collection: 'users', data: { email, password } }). With Better Auth, that user would have no accounts row and no password hash, and couldn't log in. The seed has to go through Better Auth's sign-up, which the adapter exposes as payload.betterAuth.api.signUpEmail. Three facts decide how to wire it.
Timing first. The main plugin creates the Better Auth instance in onInit, and it runs the config's existing onInit first, so a seed inside your config's onInit runs before payload.betterAuth exists. The fix is a tail plugin, placed after the auth plugins, that wraps onInit again.
Roles second. The adapter's sign-up path creates the user through its own write, and whatever your beforeValidate gives new accounts (customer, say) is what the seeded admin gets. So the seed pins the role after the account exists, every run, and doesn't trust what sign-up assigned.
Verification third, and this one is the trap. Better Auth's sign-up always writes emailVerified: false and, with sendOnSignUp on, sends the confirmation to whatever address the seed used. With requireEmailVerification: true from step 2, the seeded account can then never sign in, and viewer@example.com gets our email. The starter we ship sidesteps this by leaving verification off on demo deploys, which isn't an option on a real site. So the seed marks the account verified in the same update that pins the role, and the sign-up runs inside a scope that the step 2 sendVerificationEmail checks before it mails anyone:
An async scope, so it follows the signUpEmail call and nothing else. A visitor's sign-up never runs inside it.
import { AsyncLocalStorage } from 'node:async_hooks'
// Scope for sign-ups the site makes on its own
// behalf (seeds, provisioning). It follows the
// `signUpEmail` call and nothing else, so a
// visitor's sign-up never runs inside it.
const scope = new AsyncLocalStorage<{ seed: true }>()
export const runAsSeed = <T>(fn: () => Promise<T>) =>
scope.run({ seed: true }, fn)
export const isSeedRun = () =>
scope.getStore()?.seed === trueHere's the seed for a read-only demo viewer: an account that can open /admin and look, and can't write anything or read anything personal. The same ensureUser seeds a super-admin with a different role id.
Find-or-create through Better Auth inside the seed scope, then pin the role and mark the account verified. Re-runs touch nothing that's already right.
import type { Payload, Plugin } from 'payload'
import { VIEWER_ROLE } from '@/access/roles'
import { runAsSeed } from '@/lib/auth/provisioning'
type SignUp = (args: {
body: {
email: string
password: string
name: string
}
}) => Promise<unknown>
type WithAuth = Payload & {
betterAuth?: { api: { signUpEmail: SignUp } }
}
async function ensureViewerRole(payload: Payload) {
const found = await payload.find({
collection: 'roles',
where: { slug: { equals: VIEWER_ROLE } },
limit: 1,
})
if (found.docs[0]) return found.docs[0].id
const role = await payload.create({
collection: 'roles',
data: {
name: 'Demo Viewer',
slug: VIEWER_ROLE,
accessType: 'admin',
permissions: { canAccessAdminPanel: true },
},
})
return role.id
}
async function ensureUser(
payload: WithAuth,
email: string,
password: string,
roleId: string,
) {
const byEmail = {
collection: 'users' as const,
where: { email: { equals: email } },
limit: 1,
}
let user = (await payload.find(byEmail)).docs[0]
if (!user) {
const auth = payload.betterAuth
if (!auth) {
throw new Error('Better Auth is not attached')
}
// The same path a visitor takes, so the
// password hash and the account row are right.
// The seed scope stops the verification email.
const name = 'Demo Viewer'
await runAsSeed(() =>
auth.api.signUpEmail({
body: { email, password, name },
}),
)
user = (await payload.find(byEmail)).docs[0]
}
if (!user) throw new Error(`no user for ${email}`)
// Pin the role and mark the account verified.
// Sign-up leaves emailVerified false, and with
// verification required the seed could never
// log in otherwise.
const current = (user.roles ?? []).map((r) =>
typeof r === 'string' ? r : r.id,
)
const pinned =
current.length === 1 && current[0] === roleId
if (pinned && user.emailVerified) return
await payload.update({
collection: 'users',
id: user.id,
data: { roles: [roleId], emailVerified: true },
})
}
export async function seedDemoUsers(
payload: Payload,
) {
const password = process.env.DEMO_VIEWER_PASSWORD
// No password, no account. Never ship a default.
if (!password) return
const roleId = await ensureViewerRole(payload)
await ensureUser(
payload,
'viewer@example.com',
password,
roleId,
)
}
// Must sit AFTER the auth plugins: the auth plugin
// runs the config's onInit before it attaches
// payload.betterAuth, so a seed inside onInit
// itself would find nothing to call.
export const demoSeedPlugin: Plugin = (config) => ({
...config,
onInit: async (payload) => {
await config.onInit?.(payload)
if (process.env.DEMO_SEED === '1') {
await seedDemoUsers(payload)
}
},
})The role is accessType: 'admin' with canAccessAdminPanel and nothing else, and its slug is in ADMIN_PANEL_ROLES from step 8 but not in STAFF_ROLES. So the viewer passes both admin gates and fails every staff rule. Collections whose rows are all synthetic seed data can use staffOrViewer for read so the tour has something to show; anything that can hold a visitor's data stays on staff. On our public starter demo, that's the difference between "see the admin behind it" and handing a stranger the form submissions.
Seeding is also a single-process job. next build fans static export out across workers, each imports the config and fires onInit, and an unguarded seed runs once per worker against one database. Gate it on an environment variable that only the one process you intend sets, or on a lock, and never on NODE_ENV alone.
Common mistakes
Building the Better Auth options in two places. The collections plugin and the runtime derive different things from the same keys. A plugin added on one side, or a modelName set on one side, produces a collection that doesn't match the writes.
Deciding "first user" by counting rows. A layer that creates a system account at boot makes the count 1 before anyone signs up, and the first real person gets the default role. Count people, and name the bootstrap address in production.
Using the adapter's strategy with a roles relationship. It loads the user at depth 0, so every slug check sees bare ids and the admin gate admits nobody. Load at depth 1 or resolve the ids.
Turning on requireEmailVerification without a sender. Sign-up succeeds, sign-in fails with EMAIL_NOT_VERIFIED, and no email ever goes out. Wire sendVerificationEmail first, and backfill existing accounts as verified before enabling it on a live site.
Seeding a verified-required site without marking the seed account verified. signUpEmail writes emailVerified: false and mails the seed address. Set emailVerified: true when you pin the role, and skip the send for seed runs.
Treating a hidden button as a gate. A provider you removed from the login page is still reachable at POST /api/auth/sign-in/social. Refuse it at the endpoint, with a plugin.
Setting allowDifferentEmails for one provider and forgetting it's global. It opens every provider. Re-tighten the sign-in providers with an account.create.before hook, or don't set it at all.
Widening one admin gate. Users.access.admin and the login view's requiredRole must read the same list, or a user logs in and is refused.
Passing undefined to disable the login view's role check. It re-enables the check with the default 'admin'. Pass null.
A top-level hooks.before behind payload-auth. That adapter wraps it in a way that returned {"headers":{}} from every route. Make hooks plugins; on @delmaredigital the option is safe and a plugin is still cleaner.
Gating sign-up in a Better Auth hook. Your seeds and your checkout provisioning call auth.api.signUpEmail in-process and would need a Turnstile token too. Gate at the route, and at any server action a browser can call.
A route matcher nobody tests with a real request. A slug literal that matches nothing passes every unit test and protects nothing. Import the path from its owner and drive the handler.
Seeding users with payload.create. No accounts row, no hash, no login. Go through signUpEmail, then pin the role.
Seeding inside the config's own onInit. It runs before payload.betterAuth exists. Use a tail plugin.
How Tome does it
Tome, our Payload platform, ships this setup in its core package as a factory: one function takes a server URL, a secret, provider credentials and a few options, and returns the two adapter plugins with the role enrichment from step 8 already inside (on by default; a site that keeps its own session strategy can switch it off), the adapter's first-user hook switched off as step 8 recommends, the requiredRole: null handling from step 8 done right, dev-only localhost origins, and a collision guard that refuses to boot if another Tome layer claims a slug Better Auth needs. Tome Starter, the template new Tome sites start from, uses the factory, switches Google and GitHub on only when both of their variables exist, passes one role list to both admin gates, and seeds the demo admin and the read-only viewer the way step 12 describes, plus two guards the sample leaves out: it won't seed a super-admin in production with the default password, and it re-syncs the admin's password when the environment variable changes; it leaves email verification off on demo deploys, which is why its seed needs no verified flag.
Sites that need options the factory doesn't expose pass them through extraOptions.
The code in this walkthrough was written for this post and typechecked against a Payload 3.92 project on Better Auth 1.7.15. You can use it in your own project.
Checklist
- The Better Auth options come from one function that feeds both
betterAuthCollectionsandcreateBetterAuthPlugin. - The first admin is assigned by your own users hook through the
rolesrelationship; system accounts don't count, andBOOTSTRAP_ADMIN_EMAILis set in production. secretis set in every environment, and thejwtplugin's keys are rotated with it.- The strategy's
idTypeis'text'on MongoDB; on Postgres with serial ids, both sides are'number'andgenerateId: 'serial'is set. - The users collection has
disableLocalStrategy: trueand a strategy that loads at depth 1, or keeps the local strategy for a reason written down. readon users is staff-or-self, never "any signed-in user".- Each social provider exists only when both of its variables are set, and the front end lists providers from the same object.
- Linking is explicit (
disableImplicitLinking), or every loosened key (allowDifferentEmails,trustedProviders) has a provider that needs it and a hook that re-tightens the sign-in providers. - Better Auth is at or past 1.6.11 before linking is enabled.
- Link-only providers have
disableImplicitSignUpand a plugin that refuses/sign-in/social. - Both admin gates read one role list, the session carries the role slugs the login view checks, and
firstUserAdminis off on a relationship model. cookieCacheis a decision, and sliding sessions have a route-handler refresh.- The sign-up endpoint is gated at the REST catch-all and at any server action that calls
signUpEmail, with a test that drives the handler on the real path. - The auth probe runs on every change to
better-auth,@better-auth/*or the adapter, followed by a real staging login. - Seeds go through
signUpEmailfrom a tail plugin inside the seed scope, pin roles and setemailVerifiedafterwards, and refuse default passwords in production.
FAQ
Can I use Better Auth with Payload CMS?
Yes. The @delmaredigital/payload-better-auth package turns Better Auth's models into Payload collections, mounts Better Auth's handler at /api/auth/* as Payload endpoints, and provides an auth strategy that resolves a Better Auth session to req.user, so your existing access rules keep working. If your roles are a relationship, write the strategy yourself so the user loads at depth 1.
Does Better Auth replace Payload's admin login?
With disableLocalStrategy: true on the users collection, the plugin swaps in its own login view and the admin authenticates through Better Auth sessions. You can also keep Payload's local strategy as a fallback, which then needs a beforeValidate hook to supply a throwaway password on create, and only helps accounts whose Payload password was later set by hand.
How do I allow a provider for account linking but not for sign-in?
Set disableImplicitSignUp: true on the provider so a sign-in can't create an account, and add a Better Auth plugin whose before-hook throws a 403 APIError on /sign-in/social for that provider. Linking uses /link-social and the callback, which the hook doesn't touch.
Is allowDifferentEmails safe?
It's global, and for a provider that can sign users in it means a linked identity under a different email is a standing login into that account. Keep it off unless a link-only provider needs it, and if it's on, enforce a matching email for sign-in providers in databaseHooks.account.create.before. Also be at Better Auth 1.6.11 or later, which fixed an auto-link takeover and added requireLocalEmailVerified.
Why does the Payload admin say "Access Denied" after a successful Better Auth login?
The login view checks user.role on the session against requiredRole, which defaults to 'admin'. If your roles are a Payload relationship, enrich the session with the role slugs using customSession, and pass the same role list to requiredRole and to Users.access.admin.
How do I read the Better Auth session on the server in Next.js?
payload.auth({ headers: await headers() }) runs your Better Auth strategy and returns the Payload user document. For the raw session, payload.betterAuth.api.getSession({ headers }).
Why did every /api/auth/* route return {"headers":{}} after upgrading Better Auth?
On payload-auth, the adapter wraps the consumer's hooks.before and double-wrapped the envelope newer Better Auth (1.6.x on) returns from before-hooks, so the runner sent that object as every response. Move the hook into a plugin, and keep a probe script that drives payload.betterAuth.handler and fails on that body. @delmaredigital/payload-better-auth doesn't wrap the option.
How do I seed an admin user with Better Auth in Payload?
Call payload.betterAuth.api.signUpEmail from a plugin placed after the auth plugins (so payload.betterAuth exists), then set the role and emailVerified: true with payload.update. A plain payload.create on users leaves the account with no password hash.