12/15/2025 11:36 AM

Replacing Payload Auth with Better Auth: Stateless Social Login for SaaS Apps

6 minutes

PayloadCMS
Next.js
Better Auth

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

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:

  • Start from a standard Payload users collection
  • Disable Payload’s local auth strategy
  • Implement social login and external authentication with Better Auth
  • Match or create users in Payload and assign roles
  • Implement a custom Payload auth strategy so Payload still knows who the user is
  • Leverage Payload’s RBAC for secure, production-ready CRUD rules

The result is a SaaS-ready auth setup where social login, cookie/session handling, and Payload’s access control work seamlessly together.

Starting with a Payload Users Collection

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:

user.ts
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.

Disabling Payload’s Local Auth Strategy

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:

user.ts
1export const User: CollectionConfig = {
2  slug: "users",
3  auth: {
4    disableLocalStrategy: true,
5  },
6};
7

With 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.

Implementing Better Auth Social Login

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.

Matching or Creating 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:

auth.ts
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});
88

This setup ensures that:

  1. Every social login is tied to a Payload user.
  2. New users are created automatically if they don’t exist.
  3. User roles and metadata from the provider are synced to Payload.
  4. The user’s Payload data is stored in the Better Auth session (payloadUser) for downstream API or auth logic.

Implementing a Custom Payload Auth Strategy

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:

user.ts
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.
  • If a session exists, we extract the payloadUser stored in it.
  • Returning this object lets Payload treat the user as authenticated, enabling access to admin pages, API routes, and RBAC rules.

This approach integrates Better Auth seamlessly with Payload’s RBAC and authorization system, without relying on local passwords or the default auth flow.

Custom Admin Login and Logout Components

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:

payload.config.ts
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});

Login Button

This component calls Better Auth’s signIn API for our social provider (Zitadel) and redirects back to the admin interface:

login.tsx
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}

Logout Button

The logout component calls Better Auth’s signOut API and refreshes the admin interface:

logout.tsx
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}

Leveraging Payload’s RBAC for Secure CRUD

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

Going Further: Using Payload KV as Secondary Storage for Better Auth

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:

  • Cookies validate sessions without hitting the database.
  • Redis (or another KV store) caches session data and refreshes cookies before they expire.
  • Sessions can be revoked from the secondary storage, invalidating the cookie cache.

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:

payload.config.ts
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:

auth.ts
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});
29

This 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.

Conclusion: Flexible, SaaS-Ready Authentication with Payload and Better Auth

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:

  • Its customizable auth strategies let you fully replace local authentication.
  • RBAC and access rules remain fully functional for secure CRUD operations.
  • KV storage provides low-latency session handling and revocation.

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.


Finly — Replacing Payload Auth with Better Auth: Stateless Social Login for SaaS Apps