12/15/2025 11:36 AM
6 minutes
Payload’s built-in authentication is fine for CMS users but falls short for SaaS apps—it only supports email/password login and doesn’t handle social providers or external sessions. In this post, I show how I replaced Payload’s local auth with Better Auth, enabling social login, external session management, and role assignments, while keeping Payload as the source of truth for users. This approach lets you build a SaaS-ready authentication system without giving up Payload’s RBAC and access control.

Ravi
CTO
Payload ships with a solid authentication system—but it’s optimized for CMS content management, not for SaaS. Its local auth strategy is limited to email/username and password, which makes social login, external session handling, and modern SaaS workflows difficult.
In this post, I’ll walk through how I integrated Better Auth with Payload CMS to break free from these limitations:
The result is a SaaS-ready auth setup where social login, cookie/session handling, and Payload’s access control work seamlessly together.
Every Payload setup requires a users collection. By default, this is the "users" collection, defined under admin.user in payload.config.ts. Payload expects auth-enabled collections to include an email and password field—though we’ll be replacing the local auth strategy later.
We’ll start by defining a users collection with fields useful for syncing state from our social auth provider (Zitadel) to Payload’s database:
1import { CollectionConfig } from "payload";
2
3export const User: CollectionConfig = {
4 slug: "users",
5 admin: { useAsTitle: "email" },
6 fields: [
7 { name: "id", type: "text", required: true },
8 { name: "email", type: "email", required: true },
9 { name: "emailVerified", type: "checkbox", defaultValue: false, required: true },
10 { name: "name", type: "text", required: true },
11 { name: "profilePicture", type: "relationship", relationTo: "media" },
12 { name: "zitadelId", type: "text", admin: { readOnly: true } },
13 {
14 name: "roles",
15 type: "select",
16 options: ["admin"],
17 hasMany: true,
18 saveToJWT: true,
19 access: {
20 create: ({ req: { user } }) => user?.roles?.includes("admin") || false,
21 read: () => true,
22 update: ({ req: { user } }) => user?.roles?.includes("admin") || false,
23 },
24 },
25 ],
26};This collection will act as our source of truth for user data. Fields like zitadelId let us link users authenticated via social login back to Payload, while roles integrates seamlessly with Payload’s RBAC system.
By default, Payload’s local auth strategy automatically adds email and password fields and handles login/logout via the built-in auth pages. The downside for SaaS apps is that every user must have a password, even if they only authenticate via social login.
Some older guides suggest assigning a dummy password to bypass this requirement. This approach is risky: it increases the attack surface since all dummy passwords are predictable, and it creates unnecessary complexity for password management, resets, and potential leaks. For production-grade SaaS, it’s safer to remove the local password requirement entirely.
We can do this by disabling the local auth strategy:
1export const User: CollectionConfig = {
2 slug: "users",
3 auth: {
4 disableLocalStrategy: true,
5 },
6};
7With local auth disabled, we take full control of authentication. This allows us to implement a custom strategy for logging in users, creating accounts, and integrating social login providers without relying on Payload’s email/password system.
Next, we set up Better Auth to handle social login via our provider of choice. This involves configuring the server, client, and API routes.
For full setup instructions, follow the Better Auth installation guide. Once configured, we can move on to connecting users in Payload.
After users log in with the social provider, we need to match them with a Payload user or create one if they don’t exist. We also store the Payload user in the Better Auth token so it’s accessible through the session, allowing us to use it in API calls or a custom Payload auth strategy.
To do this, we add an additional field to the Better Auth user model and implement the getUserInfo callback:
1import { betterAuth, genericOAuth } from "better-auth";
2import jwt from "jsonwebtoken";
3import { getJwksClient, getPayload } from "./utils";
4import { User, RequiredDataFromCollectionSlug } from "@/payload-types";
5
6export const auth = betterAuth({
7 user: {
8 additionalFields: {
9 payloadUser: {
10 type: "json",
11 input: false,
12 },
13 },
14 },
15 plugins: [
16 genericOAuth({
17 config: [
18 {
19 providerId: "zitadel",
20 clientId: process.env.ZITADEL_CLIENT_ID!,
21 clientSecret: process.env.ZITADEL_CLIENT_SECRET,
22 discoveryUrl: process.env.ZITADEL_DISCOVERY_ENDPOINT,
23 getUserInfo: async ({ idToken }) => {
24 // Verify the ID token
25 const jwksClient = await getJwksClient();
26 const userInfo: jwt.JwtPayload = await new Promise((resolve, reject) => {
27 jwt.verify(
28 idToken!,
29 (header, callback) => {
30 jwksClient.getSigningKey(header.kid, (err, key) => {
31 if (err) return reject(err);
32 callback(null, key?.getPublicKey());
33 });
34 },
35 (err, decoded) => {
36 if (err) return reject(err);
37 if (typeof decoded === "object") resolve(decoded);
38 }
39 );
40 });
41
42 const payload = await getPayload({ config });
43
44 // Find existing user by email or Zitadel ID
45 const userQuery = await payload.find({
46 collection: "users",
47 where: {
48 or: [
49 { email: { equals: userInfo.email } },
50 { zitadelId: { equals: userInfo.sub } },
51 ],
52 },
53 limit: 1,
54 });
55
56 const data = {
57 email: userInfo.email,
58 emailVerified: userInfo.email_verified,
59 name: userInfo.name,
60 zitadelId: userInfo.sub as string,
61 roles: Object.keys(userInfo["urn:zitadel:iam:org:project:roles"]) as User["roles"],
62 } satisfies RequiredDataFromCollectionSlug<"users">;
63
64 let user: User;
65
66 if (userQuery.docs.length > 0) {
67 user = await payload.update({
68 collection: "users",
69 id: userQuery.docs[0].id,
70 data,
71 });
72 } else {
73 user = await payload.create({
74 collection: "users",
75 data,
76 });
77 }
78
79 return { ...user, payloadUser: user };
80 },
81 mapProfileToUser: (profile) => profile,
82 overrideUserInfo: true,
83 },
84 ],
85 }),
86 ],
87});
88This setup ensures that:
payloadUser) for downstream API or auth logic.With users logging in via a social provider and stored in Payload, we need a way for Payload to recognize the current user from the Better Auth session (stored in cookies).
In older Payload versions, this required implementing a custom Passport strategy. In v3, Payload allows custom auth callbacks directly in the collection, simplifying the process. We can add a strategy to our User collection like this:
1import { CollectionConfig } from "payload";
2import { getSession } from "./betterAuthClient";
3import { IUser } from "@/payload-types";
4
5export const User: CollectionConfig = {
6 auth: {
7 strategies: [
8 {
9 name: "better-auth",
10 authenticate: async ({ headers }) => {
11 const { data } = await getSession({
12 fetchOptions: { headers },
13 });
14
15 if (!data) return { user: null };
16
17 const { payloadUser } = data.user;
18
19 return {
20 user: {
21 collection: "users",
22 ...(payloadUser as IUser),
23 },
24 };
25 },
26 },
27 ],
28 },
29};How it works:
getSession reads the Better Auth session from cookies.payloadUser stored in it.This approach integrates Better Auth seamlessly with Payload’s RBAC and authorization system, without relying on local passwords or the default auth flow.
Since we’ve disabled Payload’s local auth strategy, the default login page is no longer available. Without a custom interface, admins would see a blank page and have to navigate back to the app’s main sign-in page—hardly ideal.
We can implement our own login and logout buttons using Payload’s custom components system. In payload.config.ts, we specify the paths for these components:
1import { buildConfig } from "payload";
2
3export default buildConfig({
4 admin: {
5 user: "users",
6 components: {
7 beforeLogin: [
8 { path: "@/components/admin/login" }
9 ],
10 logout: {
11 Button: { path: "@/components/admin/logout" }
12 },
13 },
14 },
15});This component calls Better Auth’s signIn API for our social provider (Zitadel) and redirects back to the admin interface:
1"use client";
2
3import { Button } from "@payloadcms/ui";
4import { LogIn } from "lucide-react";
5import { signInZitadel } from "@/lib/auth-client";
6
7export default function Login() {
8 return (
9 <div className="flex justify-center">
10 <Button
11 size="large"
12 buttonStyle="primary"
13 icon={<LogIn className="size-6" />}
14 onClick={() => signInZitadel({ callbackURL: "/admin" })}
15 >
16 Login
17 </Button>
18 </div>
19 );
20}The logout component calls Better Auth’s signOut API and refreshes the admin interface:
1"use client";
2
3import { Button } from "@payloadcms/ui";
4import { LogOut } from "lucide-react";
5import { signOut } from "@/lib/auth-client";
6import { useRouter } from "next/navigation";
7
8export default function Logout() {
9 const router = useRouter();
10
11 return (
12 <Button
13 buttonStyle="icon-label"
14 onClick={() =>
15 signOut().then(({ error }) => {
16 if (error) {
17 console.error(error);
18 return;
19 }
20 router.refresh();
21 })
22 }
23 >
24 <LogOut className="size-6" />
25 </Button>
26 );
27}Now that Payload can identify users from Better Auth sessions, we can fully leverage Payload’s RBAC system. Access rules can inspect the req.user object to enforce permissions on CRUD operations.
Here are some example access rules:
1import type { Access } from "payload";
2
3// General logged-in access: anyone can read, but restricted for other operations
4export const isLoggedIn: Record<"create" | "read" | "update" | "delete", Access> = {
5 create: ({ req: { user } }) => user?.roles?.includes("admin") || false,
6 read: ({ req: { user } }) => !!user,
7 update: ({ req: { user } }) => user?.roles?.includes("admin") || false,
8 delete: ({ req: { user } }) => user?.roles?.includes("admin") || false,
9};Better Auth allows you to combine stateless sessions with secondary storage, giving you the speed of cookie-based sessions while retaining the ability to revoke or refresh sessions centrally.
With this setup:
Payload 3.62.0 introduced a KV system that, while sparsely documented, can be configured with Redis for low-latency, key-value storage. For example:
1import { redisKVAdapter } from '@payloadcms/kv-redis';
2import { buildConfig } from 'payload';
3
4export default buildConfig({
5 kv: redisKVAdapter({
6 keyPrefix: "custom-prefix:", // defaults to 'payload-kv:'
7 redisURL: "redis://127.0.0.1:6379", // defaults to process.env.REDIS_URL
8 }),
9});By default, Payload’s KV uses Postgres. Using Redis improves access speed and reduces latency, making it ideal for session management.
We can then provide Payload’s KV as secondary storage for Better Auth:
1export const auth = betterAuth({
2 secondaryStorage: {
3 get: async (key) => {
4 const payload = await getPayload({ config });
5 const v = await payload.kv.get<{ value: string; exp: string | null }>(key);
6
7 if (v?.exp) {
8 const expD = new Date(v.exp);
9 if (expD.getTime() <= Date.now()) {
10 await payload.kv.delete(key);
11 return null;
12 }
13 }
14
15 return v?.value;
16 },
17 set: async (key, value, ttl) => {
18 const payload = await getPayload({ config });
19 const exp = ttl && ttl > 0 ? new Date(Date.now() + ttl * 1000).toISOString() : null;
20
21 await payload.kv.set(key, { value, exp });
22 },
23 delete: async (key) => {
24 const payload = await getPayload({ config });
25 await payload.kv.delete(key);
26 },
27 },
28});
29This approach combines stateless cookies for performance with Payload’s KV-backed storage for revocation and session control, giving a SaaS-ready authentication system that’s fast, secure, and fully integrated.
In this post, we’ve replaced Payload’s built-in auth with Better Auth, enabling social login, external session management, and full integration with Payload’s RBAC system. By connecting social provider users to Payload and using a custom auth strategy, we’ve shown how to build a production-ready SaaS authentication system without relying on email/password logins.
Using Payload’s KV as secondary storage demonstrates how stateless sessions can be combined with revocable session storage, giving the best of both worlds: fast, cookie-based validation with centralized control.
Going a step further, one could implement a custom DB adapter for Better Auth or use an existing solution like payload-auth to store sessions and user data directly in the database. This approach removes the need to store the Payload user under payloadUser in the session, provides full control over the Better Auth schema, and further streamlines the integration between Payload and your SaaS backend.
Ultimately, this setup highlights Payload v3’s flexibility and extensibility:
Combined with Next.js, Payload becomes a highly versatile platform for building modern SaaS applications with sophisticated authentication and authorization flows—without compromising on security or developer experience.