Skip to main content

Security & Lifecycle — Auth / Access Control / Hooks

Goals for this chapter

  • See what you get for free when you add auth: true to a Collection
  • Understand the function-shaped Access Control signature, and how returning a Where clause produces row-level filtering
  • Grasp the four Hook tiers (Root / Collection / Global / Field) and the main lifecycle moments
  • Be able to write the everyday patterns — audit logs, slug auto-generation, derived-field computation

Authentication

What auth does to a Collection

According to the official docs (Authentication Overview), adding auth: true to a Collection turns it into the "users" Collection, and Payload auto-generates the full authentication workflow — login, logout, password reset — and the matching Admin UI controls.

Minimal example:

import type { CollectionConfig } from 'payload'

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

Auto-generated endpoints

An auth-enabled Collection gets the following REST endpoints automatically (REST API Overview):

MethodPathPurpose
POST/api/users/loginLog in (issue JWT)
POST/api/users/logoutLog out
GET/api/users/meCurrent user
POST/api/users/refresh-tokenRefresh the token
POST/api/users/forgot-passwordTrigger a password reset

users is just the example slug; the endpoint segment follows whatever slug your Collection has.

Main options

OptionRole
tokenExpirationHow long the session stays valid (seconds)
maxLoginAttempts / lockTimeLockout controls for failed logins
verifyRequire email verification
loginWithUsernameAllow login by username (default is email)
forgotPasswordCustomize the password-reset behavior
useSessionsSession-based auth (default: true) ↔ stateless JWT (false)
useAPIKeyEnable API Keys
strategiesAdd custom auth strategies (the entry point for external IdP / Auth.js integration)
disableLocalStrategyDisable the built-in email / password auth (when delegating entirely to an external provider)

Sessions vs stateless JWT

What useSessions toggles is "session-based (stateful, default: true) vs stateless JWT" (official docs: "True by default. Set to false to use stateless JWTs for authentication instead of sessions"). Either way, delivery to the browser uses HTTP-only cookies, which the docs emphasize are safe against XSS ("HTTP-only cookies are totally protected from XSS attacks"). The practical split: a SPA on a different origin that can't carry cookies sends the token via the Authorization header; co-located with Next.js, stay on cookies.

API Keys

useAPIKey: true enables API Key issuance for third-party integrations — useful when the integrator can't carry a JWT lifecycle (webhook receivers, server-to-server callers).

Access Control

Signature

Access Control is written as functions (Access Control Overview). The argument is { req }, and req.user gets you the requester. The return value is either a boolean or a Where clause.

const isAuthenticated = ({ req: { user } }) => Boolean(user)

This is close to Payload's default: allow if authenticated, deny otherwise.

Three tiers

TierWhereRole
CollectionThe Collection's accessCRUD on the whole Collection
GlobalThe Global's accessRead / update on the Global
FieldThe Field's accessPer-field read / create / update

Operations

The Collection-level operations the official docs spell out:

  • create
  • read
  • update
  • delete

If you use Versions / Drafts you also get readVersions / publishVersion, etc. (covered in the relevant feature docs).

Returning Where = row-level filtering

Instead of a boolean, you can return a Where clause, and Payload will only let the request touch documents that match.

// Access for a Posts Collection
const access = {
read: ({ req: { user } }) => {
if (user?.role === 'admin') return true
// Regular users can only read published posts
return {
_status: { equals: 'published' },
}
},
}

Three steps: true allows everything, false denies everything, a Where clause says "only rows that match this."

Field-level access

{
name: 'secretNote',
type: 'text',
access: {
read: ({ req: { user } }) => user?.role === 'admin',
update: ({ req: { user } }) => user?.role === 'admin',
},
}

This field is only readable / writable by admins. Field-level access stacks on top of Collection-level access — both must pass.

Hooks

Four tiers

The official docs (Hooks Overview) split Hooks into four tiers:

TierWhereRole
Root HooksConfig-level hooksCross-cutting concerns like error handling
Collection HooksCollection hooksPer-Collection lifecycle
Global HooksGlobal hooksPer-Global lifecycle
Field HooksField hooksPer-field read / write logic

Main Collection Hook moments

HookWhen it fires
beforeOperationRight after the operation starts
beforeValidateJust before validation
beforeChangeAfter validation, before DB write
afterChangeAfter DB write
beforeReadBefore reading
afterReadAfter reading, before sending the response
beforeDeleteBefore delete
afterDeleteAfter delete
afterOperationAfter the operation completes

Arguments and return value

The argument object varies by Hook, but req (PayloadRequest), operation ('create' | 'update' | 'delete', etc.) are shared, and depending on the Hook you also get data (incoming payload) / doc (current DB doc) / originalDoc (before-change snapshot).

In data-touching Hooks, returning a value overwrites the data flowing through. Returning a Promise makes Payload await before proceeding.

Everyday patterns

Auto-generated slug (beforeChange)

{
slug: 'posts',
fields: [
{ name: 'title', type: 'text' },
{ name: 'slug', type: 'text', admin: { readOnly: true } },
],
hooks: {
beforeChange: [
({ data }) => {
if (data.title && !data.slug) {
data.slug = data.title.toLowerCase().replace(/\s+/g, '-')
}
return data
},
],
},
}

Audit log (afterChange)

hooks: {
afterChange: [
async ({ doc, req, operation }) => {
// payload.logger is pino-based: object first, message string second
req.payload.logger.info(
{ postId: doc.id, userId: req.user?.id },
`Post ${operation}`,
)
},
],
}

Cache invalidation (afterChange, the canonical Next.js co-location pattern)

import { revalidateTag } from 'next/cache'

hooks: {
afterChange: [
async ({ doc }) => {
revalidateTag('posts')
revalidateTag(`post-${doc.slug}`)
},
],
}

This is the heart of the Next.js cache integration covered in 06 Next.js Integration.

Field-level Hook (derived value)

{
name: 'wordCount',
type: 'number',
admin: { readOnly: true },
hooks: {
beforeChange: [
({ siblingData }) => {
const text = siblingData.body ?? ''
return text.split(/\s+/).filter(Boolean).length
},
],
},
}

siblingData lets you read other fields in the same document and compute a derived value to save.

Putting Auth × Access × Hooks together

"Only the author and admins can edit a post, anyone can read published posts, slugs auto-generate on save, and the cache is invalidated on publish" — all wrapped into one Collection:

import type { CollectionConfig } from 'payload'
import { revalidateTag } from 'next/cache'

export const Posts: CollectionConfig = {
slug: 'posts',
access: {
read: ({ req: { user } }) => {
if (user?.role === 'admin') return true
return { _status: { equals: 'published' } }
},
update: ({ req: { user } }) => {
if (!user) return false
if (user.role === 'admin') return true
return { author: { equals: user.id } }
},
delete: ({ req: { user } }) => user?.role === 'admin',
},
versions: { drafts: true },
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'slug', type: 'text', admin: { readOnly: true } },
{ name: 'body', type: 'richText' },
{ name: 'author', type: 'relationship', relationTo: 'users', required: true },
],
hooks: {
beforeChange: [
({ data }) => {
if (data.title && !data.slug) {
data.slug = data.title.toLowerCase().replace(/\s+/g, '-')
}
return data
},
],
afterChange: [
({ doc }) => {
revalidateTag('posts')
},
],
},
}

By this point, the Config-First payoff lands: the data model and the running CMS are the same thing.

Implementation example — async Access Control that checks another Collection

Access Control functions can be async. Use req.payload to query another Collection and decide based on the result — a common real-world pattern (Access Control doc).

The official example: "a customer with contracts attached cannot be deleted" — the delete access on the Customer Collection peeks into the contracts Collection and only allows the delete when there are zero related rows.

import type { Access } from 'payload'
import type { Customer } from '@/payload-types'

export const canDeleteCustomer: Access<Customer> = async ({ req, id }) => {
const result = await req.payload.find({
collection: 'contracts',
limit: 0,
depth: 0,
where: {
customer: { equals: id },
},
})

return result.totalDocs === 0
}
// Plug it into the Customers Collection's access
export const Customers: CollectionConfig = {
slug: 'customers',
access: {
delete: canDeleteCustomer,
},
fields: [/* ... */],
}

Notes:

  • limit: 0 / depth: 0 keeps the cost minimal — we only need the count
  • req.payload gives you the Local API from chapter 05 APIs
  • Returning false blocks delete consistently across the Admin UI, REST, and GraphQL
Official docs document the delete example

The official docs show the async pattern explicitly for delete. Applying the same shape to update and other operations isn't spelled out in the docs, so verify behavior when you do (the implementation surface is the same — just an async function).

Source: Access Control - Collections (official docs)

Wrap-up

  • auth: true grows a Users Collection plus the full auth workflow (login / me / refresh-token / forgot-password)
  • Access Control is written as functions; the return value is true / false / Where. A Where clause gives row-level filtering
  • Access tiers are Collection / Global / Field (three); Hook tiers are Root / Collection / Global / Field (four)
  • The four canonical Hook moments — beforeValidate / beforeChange / afterChange / afterRead — cover most everyday cases
  • Co-located with Next.js, calling revalidateTag from afterChange is the canonical pattern (see chapter 06)
  • Access Control can be async — querying other Collections via req.payload.find() to make decisions is the documented pattern

Next

In the next chapter, 05 APIs — Local / REST / GraphQL three layers, we look at the three API layers — when to use which, what calls what, and how generated types fit in.

References