payload

Use when working with Payload projects (payload.config.ts, collections, fields, hooks, access control, Payload API). Use when debugging validation errors, security issues, relationship queries, transactions, or hook behavior.

Install
npx skills add 'https://github.com/payloadcms/payload/tree/main/packages/payload/skills/payload'
Download bundle ↓
main · 9a2cdcbScanned 2026-09-17

Contributors

GitHub-linked commit authors for this SKILL.md at the saved revision. Co-authors and history before file renames are not included.

File history ↗
View on GitHub
← Back to SKILL.md

Payload Access Control Reference

Complete reference for access control patterns across collections, fields, and globals.

At a Glance

FeatureScopeReturnsUse Case
Collection Accesscreate, read, update, delete, admin, unlock, readVersionsboolean | Where queryDocument-level permissions
Field Accesscreate, read, updateboolean onlyField-level visibility/editability
Global Accessread, update, readVersionsboolean | Where queryGlobal document permissions

Three Layers of Access Control

Payload provides three distinct access control layers:

  1. Collection-Level: Controls operations on entire documents (create, read, update, delete, admin, unlock, readVersions)
  2. Field-Level: Controls access to individual fields (create, read, update)
  3. Global-Level: Controls access to global documents (read, update, readVersions)

Return Value Types

Access control functions can return:

  • Boolean: true (allow) or false (deny)
  • Query Constraint: Where object 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 { CollectionConfig, Access } 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: '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:

  1. Not Built-In: Payload does not provide a roles system out of the box. You must add a roles field to your auth collection.
  2. Save to JWT: Use saveToJWT: true to include roles in the JWT token, enabling role checks without database queries.
  3. Default Value: Set a defaultValue to automatically assign new users a default role.
  4. Access Control: Restrict who can modify roles (typically only admins).
  5. 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 { NumberField, FieldAccess } 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 { GroupField, FieldAccess } 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 { GlobalConfig, Access } 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

  1. Local API Default: Access control is skipped by default in Local API (overrideAccess: true). When passing a user parameter, you almost always want to set overrideAccess: false to 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 user without overrideAccess: false, the operation runs with admin privileges regardless of the user's actual permissions. This is a common security mistake.

  2. Field Access Limitations: Field-level access does NOT support query constraints - only boolean returns.

  3. Admin Panel Visibility: The admin access control determines if a collection appears in the admin panel for a user.

  4. Access Before Hooks: Access control executes BEFORE hooks run, so hooks cannot modify access behavior.

  5. Query Constraints: Only collection-level read access supports query constraints. All other operations and field-level access require boolean returns.

Best Practices

  1. Reusable Functions: Create named access functions for common patterns
  2. Fail Secure: Default to false for sensitive operations
  3. Cache Checks: Use req.context to cache expensive validation
  4. Type Safety: Type your user object for better IDE support
  5. Test Thoroughly: Write tests for complex access control logic
  6. Document Intent: Add comments explaining access rules
  7. Audit Logs: Track access control decisions for security review
  8. Performance: Avoid N+1 queries in access functions
  9. Error Handling: Access functions should not throw - return false instead
  10. Tenant Hooks: Auto-set tenant fields in beforeChange hooks

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.

Referenced from SKILL.md