| name | access-control |
| description | This skill should be used when the user asks about "Payload access control", "RBAC in Payload", "row-level security", "isAdmin function", "field access control", "global access control", "overrideAccess", "multi-tenant Payload", "filter by user role", "Payload Access function", or needs to decide who can create/read/update/delete documents. |
PayloadCMS — Access Control
Access control in Payload is type-safe, declarative, and lives next to the data it protects. Three layers:
- Collection access — coarse
create / read / update / delete / admin / unlock / readVersions rules per collection. Return boolean OR a Where query.
- Field access — per-field
read / create / update gates. Boolean return only.
- Global access —
read / update / readVersions on singleton globals.
The trick that unlocks everything: a function returning boolean is a global allow/deny, but a function returning a Where clause is a row-level filter — Payload merges it into every query against the collection.
Anatomy of an Access Function
import type { Access } from 'payload'
type Access = (args: {
req: PayloadRequest // contains user, locale, payload, headers
id?: string | number // present on update/delete/read-by-id
data?: Partial<TypeWithID> // present on create/update (incoming data)
}) => boolean | Where | Promise<boolean | Where>
Return values:
true — allow unconditionally.
false — deny unconditionally.
Where object — allow only rows matching this query. Payload AND-merges it with whatever the user passed.
Common Helpers
Stamp these in a src/access/ folder once and reuse:
import type { Access } from 'payload'
export const anyone: Access = () => true
export const authenticated: Access = ({ req: { user } }) => Boolean(user)
import type { User } from '@/payload-types'
export const isAdmin: Access = ({ req: { user } }) =>
(user as User | null)?.roles?.includes('admin') ?? false
export const isAdminOrSelf: Access = ({ req: { user } }) => {
if (!user) return false
if ((user as User).roles?.includes())
{ : { : user. } }
}
: = {
(!user) { : { : } }
((user ).?.())
{
: [
{ : { : } },
{ : { : user. } },
],
}
}
Collection Access
import { isAdmin, authenticated, publishedOrOwn, isAdminOrSelf } from '../access'
export const Posts: CollectionConfig = {
slug: 'posts',
access: {
read: publishedOrOwn,
create: authenticated,
update: isAdminOrSelf,
delete: isAdmin,
admin: isAdmin,
readVersions: isAdmin,
},
fields: [],
}
If access is omitted on a collection, the defaults are:
read — () => true (public).
create / update / delete — ({ req: { user } }) => Boolean(user) (logged-in users).
admin — same as update.
Field Access
Field access returns boolean only — no Where filtering:
{
name: 'salary',
type: 'number',
access: {
read: ({ req: { user } }) => user?.roles?.includes('admin') ?? false,
update: ({ req: { user } }) => user?.roles?.includes('admin') ?? false,
create: ({ req: { user } }) => user?.roles?.includes('admin') ?? false,
},
}
If read returns false, the field is stripped from the response (the document is still returned).
If update/create returns false, attempts to write the field are silently ignored.
Global Access
import { isAdmin, anyone } from '../access'
export const SiteSettings: GlobalConfig = {
slug: 'site-settings',
fields: [],
access: {
read: anyone,
update: isAdmin,
readVersions: isAdmin,
},
}
Row-Level Security (the killer feature)
Return a Where clause and Payload auto-filters every list query. Multi-tenant in 4 lines:
export const sameTenant: Access = ({ req: { user } }) => {
if (!user) return false
if ((user as User).roles?.includes('super-admin')) return true
return { tenant: { equals: (user as User).tenant } }
}
access: {
read: sameTenant,
create: sameTenant,
update: sameTenant,
delete: sameTenant,
}
For a more complex policy (members of a team OR admins):
export const teamMemberOrAdmin: Access = ({ req: { user } }) => {
if (!user) return false
if ((user as User).roles?.includes('admin')) return true
return {
'team.members': { contains: user.id },
}
}
The dot syntax (team.members) joins through the relationship — see the queries skill for nested-property queries.
CRITICAL: Local API and overrideAccess
Local API operations bypass ALL access control by default, even when you pass user:
await payload.find({
collection: 'posts',
user: someUser,
})
await payload.find({
collection: 'posts',
user: someUser,
overrideAccess: false,
})
Default behavior is the bypass because most Local API callers are trusted server code (cron jobs, hooks, migrations). When you're proxying a user request, you must opt in.
| Context | Use |
|---|
| Cron job, system task, migration | overrideAccess: true (default) |
| API route handling a user request | overrideAccess: false + pass user: req.user |
| Webhook handler | overrideAccess: false if acting on behalf of a user |
RBAC Setup
There's no built-in role model — wire it yourself on the auth collection:
export const Users: CollectionConfig = {
slug: 'users',
auth: true,
admin: { useAsTitle: 'email' },
fields: [
{
name: 'roles',
type: 'select',
hasMany: true,
saveToJWT: true,
options: [
{ label: 'Super Admin', value: 'super-admin' },
{ label: 'Admin', value: 'admin' },
{ label: 'Editor', value: 'editor' },
{ label: 'User', value: 'user' },
],
defaultValue: ['user'],
required: true,
},
],
}
saveToJWT: true writes roles into the JWT payload — that's how req.user.roles is populated on every authenticated request without re-fetching the user.
Filter Options on Relationships
Closely related — filterOptions on a relationship field is access control for "what's selectable":
{
name: 'editor',
type: 'relationship',
relationTo: 'users',
filterOptions: ({ user }) => ({
roles: { in: ['editor', 'admin'] },
}),
}
This narrows the dropdown to qualified users without touching their actual access rules.
Auth Operations & Cookies
When auth: true is set:
POST /api/users/login returns a JWT and sets an HTTP-only cookie.
POST /api/users/logout clears the cookie.
req.user is populated from the JWT on every authenticated request.
Cookie config:
auth: {
cookies: {
secure: true,
sameSite: 'Strict',
domain: '.example.com',
},
}
Where to Put Access Functions
src/
├── access/
│ ├── anyone.ts
│ ├── authenticated.ts
│ ├── isAdmin.ts
│ ├── isAdminOrSelf.ts
│ ├── sameTenant.ts
│ └── publishedOrOwn.ts
├── collections/
│ └── Posts.ts # imports from '../access'
See Also
references/advanced-patterns.md — context-aware access, time-based access, subscription gating, access function factories, multi-tenant deep dive.
- The
hooks skill — for stamping req.user.id into author automatically.
- The
queries skill — for the Where syntax used inside row-level filters.
Always run pnpm generate:types after changing roles options — the generated User['roles'] union narrows access functions.