Site icon Ampersand Tutorials

Authentication and Role-Based Access Control in Payload CMS (2026)

Quick answer: Payload’s auth system is a portable login layer you enable per-collection with auth: true — it gives you sessions, password hashing, JWT tokens and a built-in reset flow for the admin panel and your own apps. Fine-grained permissions live in access control functions that can return true, false, or a database query constraint per field, and saveToJWT lets you carry role data cheaply in the session token so you can guard admin routes, REST endpoints and Local API reads without hitting the database every time.

Part 3 of our Payload CMS series. If the framework is new to you, start with what Payload CMS is and install Payload 3 and build your first collection.

1. Auth is per-collection, not a separate service

Payload does not ship a separate user service you configure elsewhere. Authentication is a collection option: add auth: true to any collection and Payload wires sessions, hashed passwords, logins, logout, and token refresh for that collection. Most projects use one users collection, but you can auth multiple collections independently when you have a real reason.

import type { CollectionConfig } from 'payload'

export const Users: CollectionConfig = {
  slug: 'users',
  auth: true,
  admin: { useAsTitle: 'email' },
  fields: [
    { name: 'email', type: 'email', required: true, unique: true },
    { name: 'name', type: 'text' },
  ],
}

That is enough for a working login system. The scaffold creates the admin user for you; after that, new users are created through the admin panel, a custom form, or your own API endpoint.

2. What auth: true gives you for free

The auth bundle is more complete than most beginners expect. Once the flag is on, Payload provides:

FeatureWhat it does
SessionsSigned cookies plus JWTs so the admin panel and your API can stay logged in
Password hashingHandled internally — you do not wire bcrypt yourself
Login / logoutBuilt-in admin endpoints and a Local API login method on the collection
Forgot / reset passwordEmail-based reset flow with configurable URLs
LockoutTemporary lockout after repeated failed attempts, configurable in the auth options
JWT customizationAdd arbitrary claims to the token with saveToJWT on fields

You do not have to reach for a third-party OAuth provider to get a working admin login. Payload’s admin panel uses this same system, so auth-enabled collections are also the users the admin panel recognizes.

3. The one field that deserves attention early: saveToJWT

Access functions run on every read, create, update and delete. Some decisions are cheap only if the user’s role is already in hand. The saveToJWT: true option on a field puts that field’s value into the session JWT, which means you can inspect user.role without an extra database lookup.

export const Users: CollectionConfig = {
  slug: 'users',
  auth: true,
  fields: [
    { name: 'name', type: 'text' },
    {
      name: 'role',
      type: 'select',
      defaultValue: 'editor',
      options: ['admin', 'editor', 'viewer'],
      saveToJWT: true,
    },
  ],
}

With that in place, an access function can ask user?.role === 'admin' directly. Without saveToJWT, the JWT only carries the minimum identity data and you may need to fetch the user again to read custom fields — an easy source of confusion when an access rule seems to work locally but behaves differently under load.

4. Access control is the permission layer

Auth answers who is logged in. Access control answers what that person is allowed to do. Every collection and global exposes an access object with read, create, update, delete (and read on globals). Each function receives the request context including the current user when one is present.

access: {
  read: ({ req: { user } }) =>
    user ? true : { status: { equals: 'published' } },
  create: ({ req: { user } }) => Boolean(user),
  update: ({ req: { user } }) =>
    user?.role === 'admin' || user?.id === user?.id,
  delete: ({ req: { user } }) => user?.role === 'admin',
}

Three return shapes exist, and knowing the difference is the whole game:

ReturnMeaning
trueAnyone — including signed-out visitors — can perform this operation
falseNo one can perform it through this rule
A where queryOnly documents matching the constraint are visible/actionable

The query form is the one that scales safely. Returning { status: { equals: 'published' } } from read means a visitor can never even fetch a draft — the constraint is applied in the database query, not in a template you might forget to guard. Returning true from read and then hoping the front end hides drafts is exactly how drafts leak.

5. Permissions down to the field

Most tutorials stop at the collection level. The more useful level is the field. Each field can carry its own access block with read and write. That is how you let editors set a title but only admins see the internal notes field, or let everyone read a published date but only the server write it.

fields: [
  { name: 'title', type: 'text', required: true },
  {
    name: 'internalNotes',
    type: 'textarea',
    access: {
      read: ({ req: { user } }) => user?.role === 'admin',
      update: ({ req: { user } }) => user?.role === 'admin',
    },
  },
  {
    name: 'publishedAt',
    type: 'date',
    access: {
      read: true,
      update: false,
    },
  },
]

update: false on publishedAt means even a logged-in user cannot set it through the API or admin UI — the only way to write it is a hook or server code using overrideAccess. That is a clean way to make a field system-managed without hiding it from editors entirely.

6. Hooks and access interact more than you expect

Hooks run before or after a document changes. They are your place to enforce cross-field rules, compute derived values, or stamp metadata the user should not touch. They also run in the context of access: a beforeChange hook can trust that the operation already passed the collection’s access rules, but it should still be careful about operations performed with overrideAccess.

hooks: {
  beforeChange: [
    ({ data, req, operation }) => {
      if (operation === 'create' || operation === 'update') {
        if (data.status === 'published' && !data.publishedAt) {
          data.publishedAt = new Date()
        }
      }
      return data
    },
  ],
}

The common mistake here is relying on a hook to enforce permissions that belong in access. Hooks are for transformation and consistency, access functions are for permission. If a rule is about who may do this, it belongs in access. If it is about what the document should look like after, it belongs in a hook.

7. Admin panel, REST and Local API all respect the same rules — mostly

One config produces one permission model that applies across the whole system. The admin panel, the REST API at /api, and the GraphQL endpoint all enforce the collection and field access rules you wrote. Where teams get surprised is the Local API.

By default the Local API skips access control (overrideAccess defaults to true in the v3 line) because it assumes you are in trusted server code. That is correct for server-side rendering, seeds and cron jobs — but if a Local API call runs on behalf of a visitor, pass overrideAccess: false and a user:

const payload = await getPayload({ config })

const { docs } = await payload.find({
  collection: 'posts',
  user,
  overrideAccess: false,
  where: { status: { equals: 'published' } },
})

Make that explicit in your code. The v3 default and the v4 canary default differ on this setting, so code that relies on the implicit default is the kind of thing that breaks silently during a migration. Explicit is safer than clever here.

8. A realistic role model for a small editorial team

A minimal but useful permission model for a publishing site:

You can express that in a single users collection plus access functions that check user?.role. Keep the role values finite and known — a free-text role field invites drift and privilege mistakes. When you add a new role later, search the codebase for the existing string literals before you ship.

const isAdmin = (user?: any) => user?.role === 'admin'
const isEditor = (user?: any) =>
  user?.role === 'admin' || user?.role === 'editor'

access: {
  read: ({ req: { user } }) =>
    user ? true : { status: { equals: 'published' } },
  create: ({ req: { user } }) => isEditor(user),
  update: ({ req: { user } }) => isEditor(user),
  delete: ({ req: { user } }) => isAdmin(user),
}

9. The errors you will actually hit

SymptomCause and fix
Logged-in user cannot see a field in adminField-level access.read is too strict — check the field’s access block, not the collection’s
Visitor can read a draftread returns true instead of a query constraint — switch to { status: { equals: 'published' } }
Custom role field not available in accessThe field does not have saveToJWT: true, so the JWT does not carry it
user.role is undefined in accessNo user was passed into the Local API call, or the JWT was not refreshed after the role changed
A hook writes a field users should not touchHook code is running with overrideAccess: true and no guard — either tighten the hook or use field-level update: false
Password reset emails failThe auth reset URLs are not configured to a reachable host — set them explicitly for your environment
Admin login works, custom API login does notYou are calling the wrong collection’s auth methods — confirm the auth-enabled collection slug

10. Key takeaways and challenge

Challenge: take the Posts collection from the previous article and add an editorial workflow: a status field with values draft, review, published, and an editorialNotes field that only admins can read or write. Write the access rules so a non-admin editor can move a post from draft to review but cannot publish it — publication must be an admin-only transition. Then add a beforeChange hook that refuses to move a post from review straight to published unless editorialNotes is non-empty. Write down which rule stops each forbidden transition before you code any of it.

Managing permissions on a real content schema that has grown messy? Ampersand Academy runs one-to-one mentoring on TypeScript and Next.js backends, including access-control reviews. Built and run by Mahadhi — development and digital marketing.

Does Payload include authentication out of the box?

Yes. Add auth: true to a collection and Payload provides sessions, password hashing, login and logout, and a forgot and reset password flow. The admin panel uses the same system, so auth-enabled collections are the users the admin panel knows about.

Do I need a separate user service or OAuth provider to log into the admin panel?

No. The admin panel authenticates against your auth-enabled collection. You only reach for OAuth or a separate identity provider when you need social login or a centralized identity policy across multiple apps.

What does saveToJWT do, and when should I use it?

It puts a field value into the session JWT so access functions and server code can read it without an extra database lookup. Use it for fields you check often, such as a role, but keep the value small and finite because it travels in every token.

Can I restrict a single field to admins only?

Yes. Each field can have its own access block with read and update rules. Set read and update to a function that checks a role such as admin, or set update to false to make a field system-managed.

Why would a logged-in user still be blocked from seeing content?

Either the collection-level read rule returns a query constraint the user does not satisfy, or a field-level read rule is too strict. Check both, because field access is layered on top of collection access and the stricter one wins.

What is the difference between access control and hooks?

Access control decides who may perform an operation. Hooks transform or enforce consistency on a document during its lifecycle. Use access for permissions and hooks for derived values, validation across fields, and stamp-style metadata.

Does the Local API enforce access control?

By default in the v3 line it skips access control because overrideAccess defaults to true, assuming trusted server code. Pass overrideAccess as false and pass a user when the call runs on behalf of a visitor, and prefer making that explicit so a future version change cannot flip the default.

Exit mobile version