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 central piece 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')
},
],
},
}

A single Collection config now holds access control, field definitions, lifecycle hooks, and cache invalidation.

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​