Security & Lifecycle — Auth / Access Control / Hooks
Goals for this chapter
- See what you get for free when you add
auth: trueto 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):
| Method | Path | Purpose |
|---|---|---|
POST | /api/users/login | Log in (issue JWT) |
POST | /api/users/logout | Log out |
GET | /api/users/me | Current user |
POST | /api/users/refresh-token | Refresh the token |
POST | /api/users/forgot-password | Trigger a password reset |
users is just the example slug; the endpoint segment follows whatever slug your Collection has.
Main options
| Option | Role |
|---|---|
tokenExpiration | How long the session stays valid (seconds) |
maxLoginAttempts / lockTime | Lockout controls for failed logins |
verify | Require email verification |
loginWithUsername | Allow login by username (default is email) |
forgotPassword | Customize the password-reset behavior |
useSessions | Session-based auth (default: true) ↔ stateless JWT (false) |
useAPIKey | Enable API Keys |
strategies | Add custom auth strategies (the entry point for external IdP / Auth.js integration) |
disableLocalStrategy | Disable 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
| Tier | Where | Role |
|---|---|---|
| Collection | The Collection's access | CRUD on the whole Collection |
| Global | The Global's access | Read / update on the Global |
| Field | The Field's access | Per-field read / create / update |
Operations
The Collection-level operations the official docs spell out:
createreadupdatedelete
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:
| Tier | Where | Role |
|---|---|---|
| Root Hooks | Config-level hooks | Cross-cutting concerns like error handling |
| Collection Hooks | Collection hooks | Per-Collection lifecycle |
| Global Hooks | Global hooks | Per-Global lifecycle |
| Field Hooks | Field hooks | Per-field read / write logic |
Main Collection Hook moments
| Hook | When it fires |
|---|---|
beforeOperation | Right after the operation starts |
beforeValidate | Just before validation |
beforeChange | After validation, before DB write |
afterChange | After DB write |
beforeRead | Before reading |
afterRead | After reading, before sending the response |
beforeDelete | Before delete |
afterDelete | After delete |
afterOperation | After 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: 0keeps the cost minimal — we only need the countreq.payloadgives you the Local API from chapter 05 APIs- Returning
falseblocks delete consistently across the Admin UI, REST, and GraphQL
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).
Wrap-up
auth: truegrows 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
revalidateTagfromafterChangeis 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.