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 - Advanced Patterns

Advanced access control patterns including context-aware access, time-based restrictions, factory functions, and production templates.

Context-Aware Access Patterns

Locale-Specific Access

Control access based on user locale for internationalized content.

import type { Access } from 'payload'

export const localeSpecificAccess: Access = ({ req: { user, locale } }) => {
  // Authenticated users can access all locales
  if (user) return true

  // Public users can only access English content
  if (locale === 'en') return true

  return false
}

// Usage in collection
export const Posts: CollectionConfig = {
  slug: 'posts',
  access: {
    read: localeSpecificAccess,
  },
  fields: [{ name: 'title', type: 'text', localized: true }],
}

Source: docs/access-control/overview.mdx (req.locale argument)

Device-Specific Access

Restrict access based on device type or user agent.

import type { Access } from 'payload'

export const mobileOnlyAccess: Access = ({ req: { headers } }) => {
  const userAgent = headers?.get('user-agent') || ''
  return /mobile|android|iphone/i.test(userAgent)
}

export const desktopOnlyAccess: Access = ({ req: { headers } }) => {
  const userAgent = headers?.get('user-agent') || ''
  return !/mobile|android|iphone/i.test(userAgent)
}

// Usage
export const MobileContent: CollectionConfig = {
  slug: 'mobile-content',
  access: {
    read: mobileOnlyAccess,
  },
  fields: [{ name: 'title', type: 'text' }],
}

Source: Synthesized (headers pattern)

IP-Based Access

Restrict access from specific IP addresses (requires middleware/proxy headers).

import type { Access } from 'payload'

export const restrictedIpAccess = (allowedIps: string[]): Access => {
  return ({ req: { headers } }) => {
    const ip = headers?.get('x-forwarded-for') || headers?.get('x-real-ip')
    return allowedIps.includes(ip || '')
  }
}

// Usage
const internalIps = ['192.168.1.0/24', '10.0.0.5']

export const InternalDocs: CollectionConfig = {
  slug: 'internal-docs',
  access: {
    read: restrictedIpAccess(internalIps),
  },
  fields: [{ name: 'content', type: 'richText' }],
}

Note: Requires your server to pass IP address via headers (common with proxies/load balancers).

Source: Synthesized (headers pattern)

Time-Based Access Patterns

Today's Records Only

import type { Access } from 'payload'

export const todayOnlyAccess: Access = ({ req: { user } }) => {
  if (!user) return false

  const now = new Date()
  const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate())
  const endOfDay = new Date(startOfDay.getTime() + 24 * 60 * 60 * 1000)

  return {
    createdAt: {
      greater_than_equal: startOfDay.toISOString(),
      less_than: endOfDay.toISOString(),
    },
  }
}

Source: test/access-control/config.ts (query constraint patterns)

Recent Records (Last N Days)

import type { Access } from 'payload'

export const recentRecordsAccess = (days: number): Access => {
  return ({ req: { user } }) => {
    if (!user) return false
    if (user.roles?.includes('admin')) return true

    const cutoff = new Date()
    cutoff.setDate(cutoff.getDate() - days)

    return {
      createdAt: {
        greater_than_equal: cutoff.toISOString(),
      },
    }
  }
}

// Usage: Users see only last 30 days, admins see all
export const Logs: CollectionConfig = {
  slug: 'logs',
  access: {
    read: recentRecordsAccess(30),
  },
  fields: [{ name: 'message', type: 'text' }],
}

Scheduled Content (Publish Date Range)

import type { Access } from 'payload'

export const scheduledContentAccess: Access = ({ req: { user } }) => {
  // Editors see all content
  if (user?.roles?.includes('admin') || user?.roles?.includes('editor')) {
    return true
  }

  const now = new Date().toISOString()

  // Public sees only content within publish window
  return {
    and: [
      { publishDate: { less_than_equal: now } },
      {
        or: [{ unpublishDate: { exists: false } }, { unpublishDate: { greater_than: now } }],
      },
    ],
  }
}

Source: Synthesized (query constraint + date patterns)

Subscription-Based Access

Active Subscription Required

import type { Access } from 'payload'

export const activeSubscriptionAccess: Access = async ({ req: { user } }) => {
  if (!user) return false
  if (user.roles?.includes('admin')) return true

  try {
    const subscription = await req.payload.findByID({
      collection: 'subscriptions',
      id: user.subscriptionId,
    })

    return subscription?.status === 'active'
  } catch {
    return false
  }
}

// Usage
export const PremiumContent: CollectionConfig = {
  slug: 'premium-content',
  access: {
    read: activeSubscriptionAccess,
  },
  fields: [{ name: 'title', type: 'text' }],
}

Subscription Tier-Based Access

import type { Access } from 'payload'

export const tierBasedAccess = (requiredTier: string): Access => {
  const tierHierarchy = ['free', 'basic', 'pro', 'enterprise']

  return async ({ req: { user } }) => {
    if (!user) return false
    if (user.roles?.includes('admin')) return true

    try {
      const subscription = await req.payload.findByID({
        collection: 'subscriptions',
        id: user.subscriptionId,
      })

      if (subscription?.status !== 'active') return false

      const userTierIndex = tierHierarchy.indexOf(subscription.tier)
      const requiredTierIndex = tierHierarchy.indexOf(requiredTier)

      return userTierIndex >= requiredTierIndex
    } catch {
      return false
    }
  }
}

// Usage
export const EnterpriseFeatures: CollectionConfig = {
  slug: 'enterprise-features',
  access: {
    read: tierBasedAccess('enterprise'),
  },
  fields: [{ name: 'feature', type: 'text' }],
}

Source: Synthesized (async + cross-collection pattern)

Factory Functions

Reusable functions that generate access control configurations.

createRoleBasedAccess

Generate access control for specific roles.

import type { Access } from 'payload'

export function createRoleBasedAccess(roles: string[]): Access {
  return ({ req: { user } }) => {
    if (!user) return false
    return roles.some((role) => user.roles?.includes(role))
  }
}

// Usage
const adminOrEditor = createRoleBasedAccess(['admin', 'editor'])
const moderatorAccess = createRoleBasedAccess(['admin', 'moderator'])

export const Posts: CollectionConfig = {
  slug: 'posts',
  access: {
    create: adminOrEditor,
    update: adminOrEditor,
    delete: moderatorAccess,
  },
  fields: [{ name: 'title', type: 'text' }],
}

Source: test/access-control/config.ts

createOrgScopedAccess

Generate organization-scoped access with optional admin bypass.

import type { Access } from 'payload'

export function createOrgScopedAccess(allowAdmin = true): Access {
  return ({ req: { user } }) => {
    if (!user) return false
    if (allowAdmin && user.roles?.includes('admin')) return true

    return {
      organizationId: { in: user.organizationIds || [] },
    }
  }
}

// Usage
const orgScoped = createOrgScopedAccess() // Admins bypass
const strictOrgScoped = createOrgScopedAccess(false) // Admins also scoped

export const Projects: CollectionConfig = {
  slug: 'projects',
  access: {
    read: orgScoped,
    update: orgScoped,
    delete: strictOrgScoped,
  },
  fields: [
    { name: 'title', type: 'text' },
    { name: 'organizationId', type: 'text', required: true },
  ],
}

Source: test/access-control/config.ts

createTeamBasedAccess

Generate team-scoped access with configurable field name.

import type { Access } from 'payload'

export function createTeamBasedAccess(teamField = 'teamId'): Access {
  return ({ req: { user } }) => {
    if (!user) return false
    if (user.roles?.includes('admin')) return true

    return {
      [teamField]: { in: user.teamIds || [] },
    }
  }
}

// Usage with custom field name
const projectTeamAccess = createTeamBasedAccess('projectTeam')

export const Tasks: CollectionConfig = {
  slug: 'tasks',
  access: {
    read: projectTeamAccess,
    update: projectTeamAccess,
  },
  fields: [
    { name: 'title', type: 'text' },
    { name: 'projectTeam', type: 'text', required: true },
  ],
}

Source: Synthesized (org pattern variation)

createTimeLimitedAccess

Generate access limited to records within specified days.

import type { Access } from 'payload'

export function createTimeLimitedAccess(daysAccess: number): Access {
  return ({ req: { user } }) => {
    if (!user) return false
    if (user.roles?.includes('admin')) return true

    const cutoff = new Date()
    cutoff.setDate(cutoff.getDate() - daysAccess)

    return {
      createdAt: {
        greater_than_equal: cutoff.toISOString(),
      },
    }
  }
}

// Usage: Users see 90 days, admins see all
export const ActivityLogs: CollectionConfig = {
  slug: 'activity-logs',
  access: {
    read: createTimeLimitedAccess(90),
  },
  fields: [{ name: 'action', type: 'text' }],
}

Source: Synthesized (time + query pattern)

Configuration Templates

Complete collection configurations for common scenarios.

Basic Authenticated Collection

import type { CollectionConfig } from 'payload'

export const BasicCollection: CollectionConfig = {
  slug: 'basic-collection',
  access: {
    create: ({ req: { user } }) => Boolean(user),
    read: ({ req: { user } }) => Boolean(user),
    update: ({ req: { user } }) => Boolean(user),
    delete: ({ req: { user } }) => Boolean(user),
  },
  fields: [
    { name: 'title', type: 'text', required: true },
    { name: 'content', type: 'richText' },
  ],
}

Source: docs/access-control/collections.mdx

Public + Authenticated Collection

import type { CollectionConfig } from 'payload'

export const PublicAuthCollection: CollectionConfig = {
  slug: 'posts',
  access: {
    // Only admins/editors can create
    create: ({ req: { user } }) => {
      return user?.roles?.some((role) => ['admin', 'editor'].includes(role)) || false
    },

    // Authenticated users see all, public sees only published
    read: ({ req: { user } }) => {
      if (user) return true
      return { _status: { equals: 'published' } }
    },

    // Only admins/editors can update
    update: ({ req: { user } }) => {
      return user?.roles?.some((role) => ['admin', 'editor'].includes(role)) || false
    },

    // Only admins can delete
    delete: ({ req: { user } }) => {
      return user?.roles?.includes('admin') || false
    },
  },
  versions: {
    drafts: true,
  },
  fields: [
    { name: 'title', type: 'text', required: true },
    { name: 'content', type: 'richText', required: true },
    { name: 'author', type: 'relationship', relationTo: 'users' },
  ],
}

Source: templates/website/src/collections/Posts/index.ts

Multi-User/Self-Service Collection

import type { CollectionConfig } from 'payload'

export const SelfServiceCollection: CollectionConfig = {
  slug: 'users',
  auth: true,
  access: {
    // Admins can create users
    create: ({ req: { user } }) => user?.roles?.includes('admin') || false,

    // Anyone can read user profiles
    read: () => true,

    // Users can update self, admins can update anyone
    update: ({ req: { user }, id }) => {
      if (!user) return false
      if (user.roles?.includes('admin')) return true
      return user.id === id
    },

    // Only admins can delete
    delete: ({ req: { user } }) => user?.roles?.includes('admin') || false,
  },
  fields: [
    { name: 'name', type: 'text', required: true },
    { name: 'email', type: 'email', required: true },
    {
      name: 'roles',
      type: 'select',
      hasMany: true,
      options: ['admin', 'editor', 'user'],
      access: {
        // Only admins can read/update roles
        read: ({ req: { user } }) => user?.roles?.includes('admin') || false,
        update: ({ req: { user } }) => user?.roles?.includes('admin') || false,
      },
    },
  ],
}

Source: templates/website/src/collections/Users/index.ts

Debugging Tips

Log Access Check Execution

export const debugAccess: Access = ({ req: { user }, id }) => {
  console.log('Access check:', {
    userId: user?.id,
    userRoles: user?.roles,
    docId: id,
    timestamp: new Date().toISOString(),
  })
  return true
}

Verify Arguments Availability

export const checkArgsAccess: Access = (args) => {
  console.log('Available arguments:', {
    hasReq: 'req' in args,
    hasUser: args.req?.user ? 'yes' : 'no',
    hasId: args.id ? 'provided' : 'undefined',
    hasData: args.data ? 'provided' : 'undefined',
  })
  return true
}

Measure Async Operation Timing

export const timedAsyncAccess: Access = async ({ req }) => {
  const start = Date.now()

  const result = await fetch('https://auth-service.example.com/validate', {
    headers: { userId: req.user?.id },
  })

  console.log(`Access check took ${Date.now() - start}ms`)

  return result.ok
}

Test Access Without User

// In test/development
const testAccess = await payload.find({
  collection: 'posts',
  overrideAccess: false, // Enforce access control
  user: undefined, // Simulate no user
})

console.log('Public access result:', testAccess.docs.length)

Source: Synthesized (debugging best practices)

Performance Considerations

Async Operations Impact

// ❌ Slow: Multiple sequential async calls
export const slowAccess: Access = async ({ req: { user } }) => {
  const org = await req.payload.findByID({ collection: 'orgs', id: user.orgId })
  const team = await req.payload.findByID({ collection: 'teams', id: user.teamId })
  const subscription = await req.payload.findByID({ collection: 'subs', id: user.subId })

  return org.active && team.active && subscription.active
}

// ✅ Fast: Use query constraints or cache in context
export const fastAccess: Access = ({ req: { user, context } }) => {
  // Cache expensive lookups
  if (!context.orgStatus) {
    context.orgStatus = checkOrgStatus(user.orgId)
  }

  return context.orgStatus
}

Query Constraint Optimization

// ❌ Avoid: Non-indexed fields in constraints
export const slowQuery: Access = () => ({
  'metadata.internalCode': { equals: 'ABC123' }, // Slow if not indexed
})

// ✅ Better: Use indexed fields
export const fastQuery: Access = () => ({
  status: { equals: 'active' }, // Indexed field
  organizationId: { in: ['org1', 'org2'] }, // Indexed field
})

Field Access on Large Arrays

// ❌ Slow: Complex access on array fields
const arrayField: ArrayField = {
  name: 'items',
  type: 'array',
  fields: [
    {
      name: 'secretData',
      type: 'text',
      access: {
        read: async ({ req }) => {
          // Async call runs for EVERY array item
          const result = await expensiveCheck()
          return result
        },
      },
    },
  ],
}

// ✅ Fast: Simple checks or cache result
const optimizedArrayField: ArrayField = {
  name: 'items',
  type: 'array',
  fields: [
    {
      name: 'secretData',
      type: 'text',
      access: {
        read: ({ req: { user }, context }) => {
          // Cache once, reuse for all items
          if (context.canReadSecret === undefined) {
            context.canReadSecret = user?.roles?.includes('admin')
          }
          return context.canReadSecret
        },
      },
    },
  ],
}

Avoid N+1 Queries

// ❌ N+1 Problem: Query per access check
export const n1Access: Access = async ({ req, id }) => {
  // Runs for EACH document in list
  const doc = await req.payload.findByID({ collection: 'docs', id })
  return doc.isPublic
}

// ✅ Better: Use query constraint to filter at DB level
export const efficientAccess: Access = () => {
  return { isPublic: { equals: true } }
}

Performance Best Practices:

  1. Minimize Async Operations: Use query constraints over async lookups when possible
  2. Cache Expensive Checks: Store results in req.context for reuse
  3. Index Query Fields: Ensure fields in query constraints are indexed
  4. Avoid Complex Logic in Array Fields: Simple boolean checks preferred
  5. Use Query Constraints: Let database filter rather than loading all records

Source: Synthesized (operational best practices)

Enhanced Best Practices

Comprehensive security and implementation guidelines:

  1. Default Deny: Start with restrictive access, gradually add permissions
  2. Type Guards: Use TypeScript for user type safety and better IDE support
  3. Validate Data: Never trust frontend-provided IDs or data
  4. Async for Critical Checks: Use async operations for important security decisions
  5. Consistent Logic: Apply same rules at field and collection levels
  6. Test Edge Cases: Test with no user, wrong user, admin user scenarios
  7. Monitor Access: Log failed access attempts for security review
  8. Regular Audit: Review access rules quarterly or after major changes
  9. Cache Wisely: Use req.context for expensive operations
  10. Document Intent: Add comments explaining complex access rules
  11. Avoid Secrets in Client: Never expose sensitive logic to client-side
  12. Rate Limit External Calls: Protect against DoS on external validation services
  13. Handle Errors Gracefully: Access functions should return false on error, not throw
  14. Use Environment Vars: Store configuration (IPs, API keys) in env vars
  15. Test Local API: Remember to set overrideAccess: false when testing
  16. Consider Performance: Measure impact of async operations on login time
  17. Version Control: Track access control changes in git history
  18. Principle of Least Privilege: Grant minimum access required for functionality

Sources: docs/access-control/*.mdx, synthesized best practices

Referenced from SKILL.md