18 KiB
Payload CMS Access Control Reference
Complete reference for access control patterns across collections, fields, and globals.
At a Glance
| Feature | Scope | Returns | Use Case |
|---|---|---|---|
| Collection Access | create, read, update, delete, admin, unlock, readVersions | boolean | Where query | Document-level permissions |
| Field Access | create, read, update | boolean only | Field-level visibility/editability |
| Global Access | read, update, readVersions | boolean | Where query | Global document permissions |
Three Layers of Access Control
Payload provides three distinct access control layers:
- Collection-Level: Controls operations on entire documents (create, read, update, delete, admin, unlock, readVersions)
- Field-Level: Controls access to individual fields (create, read, update)
- Global-Level: Controls access to global documents (read, update, readVersions)
Return Value Types
Access control functions can return:
- Boolean:
true(allow) orfalse(deny) - Query Constraint:
Whereobject for row-level security (collection-level only)
Field-level access does NOT support query constraints - only boolean returns.
Operation Decision Tree
User makes request
│
├─ Collection access check
│ ├─ Returns false? → Deny entire operation
│ ├─ Returns true? → Continue
│ └─ Returns Where? → Apply query constraint
│
├─ Field access check (if applicable)
│ ├─ Returns false? → Field omitted from result
│ └─ Returns true? → Include field
│
└─ Operation completed
Collection Access Control
Basic Patterns
import type { Access, CollectionConfig } from 'payload';
export const Posts: CollectionConfig = {
slug: 'posts',
access: {
// Boolean: Only authenticated users can create
create: ({ req: { user } }) => Boolean(user),
// Query constraint: Public sees published, users see all
read: ({ req: { user } }) => {
if (user) return true;
return { status: { equals: 'published' } };
},
// User-specific: Admins or document owner
update: ({ req: { user }, id }) => {
if (user?.roles?.includes('admin')) return true;
return { author: { equals: user?.id } };
},
// Async: Check related data
delete: async ({ req, id }) => {
const hasComments = await req.payload.count({
collection: 'comments',
where: { post: { equals: id } },
});
return hasComments === 0;
},
// Admin panel visibility
admin: ({ req: { user } }) => {
return user?.roles?.includes('admin') || user?.roles?.includes('editor');
},
},
fields: [
{ name: 'title', type: 'text' },
{ name: 'status', type: 'select', options: ['draft', 'published'] },
{ name: 'author', type: 'relationship', relationTo: 'users' },
],
};
Role-Based Access Control (RBAC) Pattern
Payload does NOT provide a roles system by default. The following is a commonly accepted pattern for implementing role-based access control in auth collections:
import type { CollectionConfig } from 'payload';
export const Users: CollectionConfig = {
slug: 'users',
auth: true,
fields: [
{ name: 'name', type: 'text', required: true },
{ name: 'email', type: 'email', required: true },
{
name: 'roles',
type: 'select',
hasMany: true,
options: ['admin', 'editor', 'user'],
defaultValue: ['user'],
required: true,
// Save roles to JWT for access control without database lookups
saveToJWT: true,
access: {
// Only admins can update roles
update: ({ req: { user } }) => user?.roles?.includes('admin'),
},
},
],
};
Important Notes:
- Not Built-In: Payload does not provide a roles system out of the box. You must add a
rolesfield to your auth collection. - Save to JWT: Use
saveToJWT: trueto include roles in the JWT token, enabling role checks without database queries. - Default Value: Set a
defaultValueto automatically assign new users a default role. - Access Control: Restrict who can modify roles (typically only admins).
- Role Options: Define your own role hierarchy based on your application needs.
Using Roles in Access Control:
import type { Access } from 'payload';
// Check for specific role
export const adminOnly: Access = ({ req: { user } }) => {
return user?.roles?.includes('admin');
};
// Check for multiple roles
export const adminOrEditor: Access = ({ req: { user } }) => {
return Boolean(
user?.roles?.some((role) => ['admin', 'editor'].includes(role)),
);
};
// Role hierarchy check
export const hasMinimumRole: Access = ({ req: { user } }, minRole: string) => {
const roleHierarchy = ['user', 'editor', 'admin'];
const userHighestRole = Math.max(
...(user?.roles?.map((r) => roleHierarchy.indexOf(r)) || [-1]),
);
const requiredRoleIndex = roleHierarchy.indexOf(minRole);
return userHighestRole >= requiredRoleIndex;
};
Reusable Access Functions
import type { Access } from 'payload';
// Anyone (public)
export const anyone: Access = () => true;
// Authenticated only
export const authenticated: Access = ({ req: { user } }) => Boolean(user);
// Authenticated or published content
export const authenticatedOrPublished: Access = ({ req: { user } }) => {
if (user) return true;
return { _status: { equals: 'published' } };
};
// Admin only
export const admins: Access = ({ req: { user } }) => {
return user?.roles?.includes('admin');
};
// Admin or editor
export const adminsOrEditors: Access = ({ req: { user } }) => {
return Boolean(
user?.roles?.some((role) => ['admin', 'editor'].includes(role)),
);
};
// Self or admin
export const adminsOrSelf: Access = ({ req: { user } }) => {
if (user?.roles?.includes('admin')) return true;
return { id: { equals: user?.id } };
};
// Usage
export const Posts: CollectionConfig = {
slug: 'posts',
access: {
create: authenticated,
read: authenticatedOrPublished,
update: adminsOrEditors,
delete: admins,
},
fields: [{ name: 'title', type: 'text' }],
};
Row-Level Security with Complex Queries
import type { Access } from 'payload';
// Organization-scoped access
export const organizationScoped: Access = ({ req: { user } }) => {
if (user?.roles?.includes('admin')) return true;
// Users see only their organization's data
return {
organization: {
equals: user?.organization,
},
};
};
// Multiple conditions with AND
export const complexAccess: Access = ({ req: { user } }) => {
return {
and: [
{ status: { equals: 'published' } },
{ 'author.isActive': { equals: true } },
{
or: [
{ visibility: { equals: 'public' } },
{ author: { equals: user?.id } },
],
},
],
};
};
// Team-based access
export const teamMemberAccess: Access = ({ req: { user } }) => {
if (!user) return false;
if (user.roles?.includes('admin')) return true;
return {
'team.members': {
contains: user.id,
},
};
};
Header-Based Access (API Keys)
import type { Access } from 'payload';
export const apiKeyAccess: Access = ({ req }) => {
const apiKey = req.headers.get('x-api-key');
if (!apiKey) return false;
// Validate against stored keys
return apiKey === process.env.VALID_API_KEY;
};
// Bearer token validation
export const bearerTokenAccess: Access = async ({ req }) => {
const auth = req.headers.get('authorization');
if (!auth?.startsWith('Bearer ')) return false;
const token = auth.slice(7);
const isValid = await validateToken(token);
return isValid;
};
Field Access Control
Field access does NOT support query constraints - only boolean returns.
Basic Field Access
import type { FieldAccess, NumberField } from 'payload';
const salaryReadAccess: FieldAccess = ({ req: { user }, doc }) => {
// Self can read own salary
if (user?.id === doc?.id) return true;
// Admin can read all salaries
return user?.roles?.includes('admin');
};
const salaryUpdateAccess: FieldAccess = ({ req: { user } }) => {
// Only admins can update salary
return user?.roles?.includes('admin');
};
const salaryField: NumberField = {
name: 'salary',
type: 'number',
access: {
read: salaryReadAccess,
update: salaryUpdateAccess,
},
};
Sibling Data Access
import type { ArrayField, FieldAccess } from 'payload';
const contentReadAccess: FieldAccess = ({ req: { user }, siblingData }) => {
// Authenticated users see all
if (user) return true;
// Public sees only if marked public
return siblingData?.isPublic === true;
};
const arrayField: ArrayField = {
name: 'sections',
type: 'array',
fields: [
{
name: 'isPublic',
type: 'checkbox',
defaultValue: false,
},
{
name: 'content',
type: 'text',
access: {
read: contentReadAccess,
},
},
],
};
Nested Field Access
import type { FieldAccess, GroupField } from 'payload';
const internalOnlyAccess: FieldAccess = ({ req: { user } }) => {
return user?.roles?.includes('admin') || user?.roles?.includes('internal');
};
const groupField: GroupField = {
name: 'internalMetadata',
type: 'group',
access: {
read: internalOnlyAccess,
update: internalOnlyAccess,
},
fields: [
{ name: 'internalNotes', type: 'textarea' },
{ name: 'priority', type: 'select', options: ['low', 'medium', 'high'] },
],
};
Hiding Admin Fields
import type { CollectionConfig } from 'payload';
export const Users: CollectionConfig = {
slug: 'users',
auth: true,
fields: [
{ name: 'name', type: 'text', required: true },
{ name: 'email', type: 'email', required: true },
{
name: 'roles',
type: 'select',
hasMany: true,
options: ['admin', 'editor', 'user'],
access: {
// Hide from UI, but still saved/queried
read: ({ req: { user } }) => user?.roles?.includes('admin'),
// Only admins can update roles
update: ({ req: { user } }) => user?.roles?.includes('admin'),
},
},
],
};
Global Access Control
import type { Access, GlobalConfig } from 'payload';
const adminOnly: Access = ({ req: { user } }) => {
return user?.roles?.includes('admin');
};
export const SiteSettings: GlobalConfig = {
slug: 'site-settings',
access: {
read: () => true, // Anyone can read settings
update: adminOnly, // Only admins can update
readVersions: adminOnly, // Only admins can see version history
},
fields: [
{ name: 'siteName', type: 'text' },
{ name: 'maintenanceMode', type: 'checkbox' },
],
};
Multi-Tenant Access Control
import type { Access, CollectionConfig } from 'payload';
// Add tenant field to user type
interface User {
id: string;
tenantId: string;
roles?: string[];
}
// Tenant-scoped access
const tenantAccess: Access = ({ req: { user } }) => {
// No user = no access
if (!user) return false;
// Super admin sees all
if (user.roles?.includes('super-admin')) return true;
// Users see only their tenant's data
return {
tenant: {
equals: (user as User).tenantId,
},
};
};
export const Posts: CollectionConfig = {
slug: 'posts',
access: {
create: tenantAccess,
read: tenantAccess,
update: tenantAccess,
delete: tenantAccess,
},
fields: [
{ name: 'title', type: 'text' },
{
name: 'tenant',
type: 'text',
required: true,
access: {
// Tenant field hidden from non-admins
update: ({ req: { user } }) => user?.roles?.includes('super-admin'),
},
hooks: {
// Auto-set tenant on create
beforeChange: [
({ req, operation, value }) => {
if (operation === 'create' && !value) {
return (req.user as User)?.tenantId;
}
return value;
},
],
},
},
],
};
Auth Collection Patterns
Self or Admin Pattern
import type { CollectionConfig } from 'payload';
export const Users: CollectionConfig = {
slug: 'users',
auth: true,
access: {
// Anyone can read user profiles
read: () => true,
// Users can update themselves, admins can update anyone
update: ({ req: { user }, id }) => {
if (user?.roles?.includes('admin')) return true;
return user?.id === id;
},
// Only admins can delete
delete: ({ req: { user } }) => user?.roles?.includes('admin'),
},
fields: [
{ name: 'name', type: 'text' },
{ name: 'email', type: 'email' },
],
};
Restrict Self-Updates
import type { CollectionConfig, FieldAccess } from 'payload';
const preventSelfRoleChange: FieldAccess = ({ req: { user }, id }) => {
// Admins can change anyone's roles
if (user?.roles?.includes('admin')) return true;
// Users cannot change their own roles
if (user?.id === id) return false;
return false;
};
export const Users: CollectionConfig = {
slug: 'users',
auth: true,
fields: [
{
name: 'roles',
type: 'select',
hasMany: true,
options: ['admin', 'editor', 'user'],
access: {
update: preventSelfRoleChange,
},
},
],
};
Cross-Collection Validation
import type { Access } from 'payload';
// Check if user is a project member before allowing access
export const projectMemberAccess: Access = async ({ req, id }) => {
const { user, payload } = req;
if (!user) return false;
if (user.roles?.includes('admin')) return true;
// Check if document exists and user is member
const project = await payload.findByID({
collection: 'projects',
id: id as string,
depth: 0,
});
return project.members?.includes(user.id);
};
// Prevent deletion if document has dependencies
export const preventDeleteWithDependencies: Access = async ({ req, id }) => {
const { payload } = req;
const dependencyCount = await payload.count({
collection: 'related-items',
where: {
parent: { equals: id },
},
});
return dependencyCount === 0;
};
Access Control Function Arguments
Collection Create
create: ({ req, data }) => boolean | Where;
// req: PayloadRequest
// - req.user: Authenticated user (if any)
// - req.payload: Payload instance for queries
// - req.headers: Request headers
// - req.locale: Current locale
// data: The data being created
Collection Read
read: ({ req, id }) => boolean | Where;
// req: PayloadRequest
// id: Document ID being read
// - undefined during Access Operation (login check)
// - string when reading specific document
Collection Update
update: ({ req, id, data }) => boolean | Where;
// req: PayloadRequest
// id: Document ID being updated
// data: New values being applied
Collection Delete
delete: ({ req, id }) => boolean | Where
// req: PayloadRequest
// id: Document ID being deleted
Field Create
access: {
create: ({ req, data, siblingData }) => boolean;
}
// req: PayloadRequest
// data: Full document data
// siblingData: Adjacent field values at same level
Field Read
access: {
read: ({ req, id, doc, siblingData }) => boolean;
}
// req: PayloadRequest
// id: Document ID
// doc: Full document
// siblingData: Adjacent field values
Field Update
access: {
update: ({ req, id, data, doc, siblingData }) => boolean;
}
// req: PayloadRequest
// id: Document ID
// data: New values
// doc: Current document
// siblingData: Adjacent field values
Important Notes
-
Local API Default: Access control is skipped by default in Local API (
overrideAccess: true). When passing auserparameter, you almost always want to setoverrideAccess: falseto respect that user's permissions:// ❌ WRONG: Passes user but bypasses access control (default behavior) await payload.find({ collection: 'posts', user: someUser, // User is ignored for access control! }); // ✅ CORRECT: Respects the user's permissions await payload.find({ collection: 'posts', user: someUser, overrideAccess: false, // Required to enforce access control });Why this matters: If you pass
userwithoutoverrideAccess: false, the operation runs with admin privileges regardless of the user's actual permissions. This is a common security mistake. -
Field Access Limitations: Field-level access does NOT support query constraints - only boolean returns.
-
Admin Panel Visibility: The
adminaccess control determines if a collection appears in the admin panel for a user. -
Access Before Hooks: Access control executes BEFORE hooks run, so hooks cannot modify access behavior.
-
Query Constraints: Only collection-level
readaccess supports query constraints. All other operations and field-level access require boolean returns.
Best Practices
- Reusable Functions: Create named access functions for common patterns
- Fail Secure: Default to
falsefor sensitive operations - Cache Checks: Use
req.contextto cache expensive validation - Type Safety: Type your user object for better IDE support
- Test Thoroughly: Write tests for complex access control logic
- Document Intent: Add comments explaining access rules
- Audit Logs: Track access control decisions for security review
- Performance: Avoid N+1 queries in access functions
- Error Handling: Access functions should not throw - return
falseinstead - Tenant Hooks: Auto-set tenant fields in
beforeChangehooks
Advanced Patterns
For advanced access control patterns including context-aware access, time-based restrictions, subscription-based access, factory functions, configuration templates, debugging tips, and performance optimization, see ACCESS-CONTROL-ADVANCED.md.