Skip to content

Payload access control when your app already has auth

Two user tables is the right answer. Map your app's roles onto Payload's rules instead of merging the tables, and never leave an access block undeclared.

Payload blog4 min readships at docs/solutions/blog-payload/access-control-matching-your-auth-battery.md

Tags: payload · access-control · auth · roles · security

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.user object 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:

  1. Anonymous. /cms should redirect to login. /cms-api/posts should return published documents only. /cms-api/users should return nothing useful.
  2. 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.
  3. 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.