Use Better Auth as a Custom Auth Strategy in Payload CMS

Here’s how I replaced Payload CMS’s built-in login system with Better Auth so an Next.js site and its Payload admin panel could share the same authenticated session.

Using Payload CMS

If you’re comfortable working in a Next.js app, and you haven’t given Payload CMS a try, you really should. It’s a config-first Typescript CMS that slots into your Next.js app easily. And as of recently, they offer an input file to orient your LLM to their patterns, if that’s your sort of thing.

Payload has a ton of nice out-of-the-box features, including Auth. With a single line of config on your Users collection, you get email-and-password login protecting your admin panel. From there, role-based access-control (RBAC) is a breeze to add. Just add a “roles” field on your Users collection, and apply Payload’s built-in access control functions to protect sensitive admin-only data.

Enter Better Auth

Cool, but what if you want to use other auth patterns, like one-time passwords, or OIDC?

Enter Better Auth. Over the years, I’ve tried many auth libraries in the Node ecosystem, and this one finally seems to have hit the mark, with good documentation, tons of plugins, and sensible configuration. It lets me manage its underlying DB tables, which I like, and I have yet to hit a dead-end developing with it. It also offers an input file to orient your LLM to their patterns. So, how do we use it with Payload?

I answered that question on a recent project, where my website and CMS needed to share the same auth session. “Editor” users who logged into the website would see an extra “View Editor” button on their account page. Clicking it would drive them to the Payload admin panel, where they’d already be logged in, ready to edit the website.

To achieve that, I set up Better Auth with its Email OTP (one-time password) plugin. I created a two-step form to collect the user’s email, use Better Auth to generate a one-time password, and email it to them with Resend , then allow them to enter the OTP and verify it with Better Auth.

From the website’s perspective, the auth work is done. Enter your email, now enter the code we sent you, and boom, you’ve got a secure auth cookie minted by Better Auth to prove you’re legit. Try to visit the Payload admin panel, though, and you’ll hit Payload’s standard email-and-password login screen. It’s still using its default auth system. We need to configure Payload to look for the Better Auth token, and resolve it to the right record in the Users collection. In the terms used by the Payload documentation, we need to implement a custom auth strategy. This is a function that defines how to authenticate an incoming request to the CMS.

Custom Strategy

Here’s the custom strategy I ended up writing.

import { auth } from '@/lib/auth'
import { AuthStrategy } from 'payload'

const betterAuthStrategy: AuthStrategy = {
  name: 'better-auth-strategy',
  authenticate: async ({ payload, headers }) => {
    try {
	  // Use Better Auth's server-side "auth" object to extract a session.
      const session = await auth.api.getSession({
        headers,
      })
      // No session, no user.
      if (!session) {
        return {
          user: null,
        }
      }
      // We have a session, so see if any user emails match.
      const payloadUsers = await payload.find({
        collection: 'users',
        where: {
          email: {
            equals: session.user.email,
          },
        },
      })
      // At least one matched! Return it!
      if (payloadUsers.docs.length > 0) {
        return {
          user: {
            collection: 'users',
            ...payloadUsers.docs[0],
          },
        }
      }
      // Wait, no users matched...

      // If this is the first user, create the initial admin account.
      // Note: this is just one convenient way to bootstrap the initial admin,
      // and can be skipped if your team would prefer a seeded admin,
      // or another separate provisioning step.
      const userCount = await payload.count({
        collection: 'users',
      })
      const isFirstUser = userCount.totalDocs == 0
      if (isFirstUser) {
        const newPayloadUser = await payload.create({
          collection: 'users',
          data: {
            email: session.user.email,
            name: 'First User',
            roles: ['admin'],
          },
        })
        return {
          user: {
            collection: 'users',
            ...newPayloadUser,
          },
        }
      }

      // I have never met this man in my life.
      return {
        user: null,
      }
    } catch {
      // Assume no user if something fails.
      return {
        user: null,
      }
    }
  },
}

This strategy uses Better Auth’s server-side auth object to retrieve the active session from request headers, search Payload for a user with a matching email, and optionally create an initial admin user if the collection is empty. To use it, we just have to plug the function into the auth configuration section of the Users collection.

import { CollectionConfig } from 'payload'

export const Users: CollectionConfig = {
	// ...
	auth: {
		disableLocalStrategy: true,
		strategies: [betterAuthStrategy],
	},
}

Setting disableLocalStrategy to true disables Payload’s default auth mechanisms, and hides its email-and-password login form. This is what we want, since we’ve now attached our custom auth strategy. But to finish the implementation, we also have to add our custom login form back into the Payload UI. This way, if a user lands on the CMS admin panel first, they can sign in there directly, instead of requiring them to go through the website login page. This can be done very simply. Just add component paths in your payload.config.ts file.

import { buildConfig } from 'payload'

export default buildConfig({
  // ...
  admin: {
    // ...
    components: {
      // ...
      // Here, you could use the same LoginForm component used by the website.
      afterLogin: ['/path/to/your/LoginForm'],
      // You'll want to make a component to sign out using Better Auth too!
      logout: {
        Button: '/path/to/your/LogoutButton',
      },
    },
  },
})

With this Better Auth approach, you can radically expand the possible auth flows used by a Next.js/Payload app.

Conversation

Join the conversation

Your email address will not be published. Required fields are marked *