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

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:

```ts
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`:

```ts
{
  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:

```ts
// 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:

```ts
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".**

```ts
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:

```ts
where: { _status: { equals: "published" } }
```

or opt into the rules you already wrote:

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

---

Agentic Boilerplate: A Next.js repo your agent already knows. Free during launch, then $99 once.

- Site map for agents: https://agenticboilerplate.com/llms.txt
- Public API: https://agenticboilerplate.com/openapi.json
- Contact: agenticstudio@gmail.com
