Your app already has authentication (Better Auth, Clerk, Supabase, whatever you
picked) with its own users table and its own sessions. Now Payload arrives
with a users collection and a login page of its own, and the obvious question
is how to make them one thing.
The obvious answer, "point Payload at my existing users table", is almost always wrong. Here is the reasoning, and then the two setups that do work.
Why not merge them
Payload's auth collection is not just a table. It owns:
- password hashing with its own parameters,
- session tokens signed with
PAYLOAD_SECRET, - login attempt counting, lockouts, verification and reset flows,
- the
req.userobject every access rule reads.
Your auth battery owns all the same things, differently. Merging means one of them stops being able to do its job, usually Payload, which then cannot log anyone into the admin panel.
There is also a security argument that matters more than the plumbing one: your application's users are not your editors. If a reader who signed up this morning exists in the same table that grants CMS access, then the distance between "customer" and "can edit the homepage" is one boolean, and that boolean is reachable by every code path that touches user records.
Two tables is not duplication. It is a boundary.
Setup 1: keep them separate (the default here)
CMS accounts live in Payload's users collection. There is no public sign-up:
access: {
create: isAdmin, // only an admin creates editor accounts
read: isSelfOrAdmin,
update: isSelfOrAdmin,
delete: isAdmin,
admin: ({ req }) => Boolean(req.user), // may open /cms at all
},
Your editors (there are usually between two and ten of them) get an invitation
from an admin. Everyone else uses the app's own auth and never sees /cms.
The one rule people forget is field-level access on roles:
{
name: "roles",
type: "select",
hasMany: true,
options: [{ label: "Admin", value: "admin" }, { label: "Editor", value: "editor" }],
access: { create: adminFieldOnly, update: adminFieldOnly },
}
Without it, an editor can promote themselves to admin. The collection's update
rule already lets them save their own document, and a field with no access rule
inherits the collection's.
Setup 2: your auth decides who may enter
If you genuinely need one login, do not merge the tables: bridge them. Keep Payload's users as the CMS identity and provision them from your app:
// when someone in your app is granted the editor role
const payload = await getPayloadClient();
const existing = await payload.find({
collection: "users",
where: { email: { equals: appUser.email } },
limit: 1,
});
if (existing.docs.length === 0) {
await payload.create({
collection: "users",
data: { email: appUser.email, name: appUser.name, roles: ["editor"], password: randomPassword() },
});
}
and de-provision on the way out: revoking the app role should delete or disable the Payload account in the same transaction. A bridge that only creates accounts is how a former employee keeps CMS access.
For true single sign-on, Payload supports custom auth strategies, where you validate your own session cookie and return the Payload user it maps to. It is more code than it looks, and it is the right investment only when you have enough editors that separate credentials are a real cost.
Declare all four operations, always
Payload's default when an access block is missing is "any authenticated user
may do it". Not "nobody". So an empty access on a collection means every
editor can delete every document in it.
Every collection in this repo declares all four:
access: {
read: isPublishedOrEditor,
create: isEditor,
update: isEditor,
delete: isEditor,
},
Two habits that make these rules better:
Return a query constraint, not a boolean, when the answer is "some rows".
export const isPublishedOrEditor: Access = ({ req }) => {
if (rolesOf(req.user).length > 0) return true;
return { _status: { equals: "published" } };
};
Payload turns that object into a WHERE clause. An anonymous reader gets
published documents instead of a 403 on the whole collection, and you did not
have to write two code paths.
Write isPublic rather than omitting the rule. read: isPublic is a
decision someone made; a missing read is a decision nobody made.
Remember what the local API does to all of this
Access rules apply to the REST and GraphQL APIs and to the admin panel. The
local API defaults to overrideAccess: true, so a server component reading
payload.find({ collection: "posts" }) sees drafts and everything else,
regardless of what you wrote above.
On any page a visitor can reach, either filter explicitly:
where: { _status: { equals: "published" } }
or opt into the rules you already wrote:
await payload.find({ collection: "posts", overrideAccess: false, user });
Testing it
Never test access control as the admin you are logged in as. Make three browsers or three profiles:
- Anonymous.
/cmsshould redirect to login./cms-api/postsshould return published documents only./cms-api/usersshould return nothing useful. - Editor. Can create and publish posts. Cannot create a user. Cannot
change their own
roles: check by editing the profile and saving; the field should be read-only or the change should be rejected. - Admin. Can do all of it.
Then re-run those three after every access change. It takes two minutes and it is the only way to find the rule you thought you wrote.