Now booking enterprise content platform builds for 2026. Contact us

All articles Engineering 10 min read

Building Field-Level RBAC in Payload CMS

Payload ships with access control but no roles. Here's how we built a role-based access control plugin for Payload 3: per-collection and per-field CRUD permissions, multi-tenant scoping, and a tree UI for authoring it all — enforced without rewriting a single collection's access function.


Payload ships with access control but no roles. You write an access function per collection, check user.roles by hand, and repeat that logic everywhere. That works until an editor needs to update a post’s body but not its publish date, or a client admin needs full control over their own tenant and nothing outside it. At that point you want roles that carry permissions down to individual fields, and you want them enforced without rewriting every collection.

This is how we built that: a role-based access control (RBAC) plugin for Payload 3 that gives administrators per-collection and per-field CRUD permissions, scopes everything to a tenant in multi-tenant setups, and edits all of it from a tree UI on the role document. The post assumes you’re comfortable with Payload collections, hooks, the Local API, and TypeScript. We’ll go feature by feature, then through the three phases that make each one work: config-time wiring, authoring in the admin panel, and enforcement at request time.

The feature set at a glance

The plugin adds a roles collection where each role holds a permission tree, then wraps every collection and field access function so reads, writes, creates, and deletes are gated by the acting user’s roles. Administrators toggle CRUD flags per collection and per field; the plugin resolves a user’s effective permissions on each request and enforces them. Super admins bypass everything.

Concretely, the feature set is:

  • Per-collection CRUD: a role can read posts, update posts, but not delete them.
  • Per-field CRUD: within posts, a role can read and update title but not even read internalNotes.
  • Multi-tenant scoping: roles belong to a tenant, and permissions only apply inside that tenant.
  • Bypass rules: super admins skip all checks; users can always read and update their own user document.
  • A tree editor in the admin panel for authoring permissions without touching JSON.

None of this changes how collection authors write their own access functions. The plugin wraps whatever is already there and only ever narrows it, so RBAC is additive rather than a rewrite.

The data model: roles and permissions

A role is a document with a name, a users relationship that holds membership, a tenant relationship in multi-tenant mode, and a JSON permission tree keyed by collection slug. Each collection entry carries the collection’s own CRUD booleans plus a map of field paths to their CRUD booleans. Membership lives on the role, not on the user.

// types.ts
export type CrudFlags = {
  read: boolean
  create: boolean
  update: boolean
  delete: boolean
}

export type CollectionPermission = CrudFlags & {
  // field path -> that field's own CRUD flags
  fields?: Record<string, CrudFlags>
}

// keyed by collection slug, e.g. { posts: {...}, media: {...} }
export type Permissions = Record<string, CollectionPermission>
// collections/Roles.ts
import type { CollectionConfig } from 'payload'

export const Roles: CollectionConfig = {
  slug: 'roles',
  admin: { useAsTitle: 'name', group: 'Access' },
  access: {
    // Strict. Internal permission resolution does NOT go through these (see below).
    read: ({ req: { user } }) => Boolean(user?.isAdmin),
    create: ({ req: { user } }) => Boolean(user?.isAdmin),
    update: ({ req: { user } }) => Boolean(user?.isAdmin),
    delete: ({ req: { user } }) => Boolean(user?.isAdmin),
  },
  fields: [
    { name: 'name', type: 'text', required: true },
    { name: 'tenant', type: 'relationship', relationTo: 'tenants' }, // MT only
    { name: 'users', type: 'relationship', relationTo: 'users', hasMany: true },
    { name: 'permissions', type: 'json' }, // Permissions shape above
  ],
}

One production note worth deciding up front: we don’t store permissions as a single JSON blob. We split them into one JSON column per collection (permissions_posts, permissions_media, and so on). It lets a permission lookup select a single column instead of loading the whole tree, and lets the save path send only the columns that changed. With more than a handful of collections, that split is the difference between snappy saves and re-validating two dozen unchanged columns on every write. The single-blob model above is easier to read; reach for the split when the collection count grows.

How does the plugin enforce permissions?

Enforcement is wiring, not runtime cleverness. A Payload plugin is a curried function: it takes options, then the config, then returns a new config. At boot we map over every collection and replace its access functions with wrappers that check the role permissions first and defer to the original function second. Because this happens once at startup, the only per-request cost is the permission lookup itself.

// plugin/index.ts
import type { Access, Config, Plugin, Where } from 'payload'
import { Roles } from '../collections/Roles'
import { resolveCollectionFlags } from './resolve'

type Operation = 'read' | 'create' | 'update' | 'delete'

interface RbacOptions {
  usersSlug: string
  isSuperAdmin: (user: unknown) => boolean
  excludedCollections?: string[]
}

const mergeWhere = (rbacWhere: Where, original?: boolean | Where): boolean | Where => {
  if (original === false) return false
  if (original === true || original === undefined) return rbacWhere
  return { and: [rbacWhere, original] }
}

const withCollectionRbac =
  (op: Operation, slug: string, original: Access | undefined, opts: RbacOptions): Access =>
  async (args) => {
    const { req } = args
    const user = req.user
    if (!user) return false
    if (opts.isSuperAdmin(user)) return original ? original(args) : true

    const flags = await resolveCollectionFlags(req, slug)
    if (flags?.[op]) {
      // Granted. Defer to the collection's own access so it can narrow further.
      return original ? original(args) : true
    }

    // Permission miss: allow any user to read/update their own user document.
    if (slug === opts.usersSlug && (op === 'read' || op === 'update')) {
      const self: Where = { id: { equals: user.id } }
      return mergeWhere(self, original ? await original(args) : undefined)
    }
    return false
  }

export const rbac =
  (opts: RbacOptions): Plugin =>
  (config: Config): Config => ({
    ...config,
    collections: [
      ...(config.collections ?? []).map((collection) => {
        if (opts.excludedCollections?.includes(collection.slug)) return collection
        const a = collection.access ?? {}
        return {
          ...collection,
          access: {
            ...a,
            read: withCollectionRbac('read', collection.slug, a.read, opts),
            create: withCollectionRbac('create', collection.slug, a.create, opts),
            update: withCollectionRbac('update', collection.slug, a.update, opts),
            delete: withCollectionRbac('delete', collection.slug, a.delete, opts),
          },
          // field-level wrapping added in the next section
        }
      }),
      Roles,
    ],
  })

Two design choices live in that wrapper. RBAC always ANDs with the original access function instead of replacing it, so a collection author’s row-level Where still applies after RBAC says yes. And the users collection gets a self-access fallback: even with no matching role, an authenticated user can read and update their own record, which is what you want for profile pages and password changes.

Don’t be tempted to exclude the roles collection from RBAC to keep things simple. It decides everyone’s permissions; leaving it unguarded is a hole. Excluded collections are for things genuinely outside the permission model, like a system locales table.

How does field-level access work?

Field access in Payload returns booleans only, never a Where. To gate fields, the plugin recurses the schema at config time, builds a stable path for each field, and wraps that field’s read, create, and update access functions (fields have no delete). At request time the wrapper checks whether the field’s path is permitted for the operation.

// plugin/fieldAccess.ts
import type { Field, FieldAccess } from 'payload'
import { resolveCollectionFlags } from './resolve'

const buildPath = (parent: string, index: number, name: string) => `${parent}.${index}.${name}`

const withFieldRbac =
  (op: 'read' | 'create' | 'update', slug: string, path: string, original?: FieldAccess): FieldAccess =>
  async (args) => {
    const flags = await resolveCollectionFlags(args.req, slug)
    const fieldFlags = flags?.fields?.[path]
    // Unconfigured field: defer to original (don't deny by accident).
    if (fieldFlags && !fieldFlags[op]) return false
    return original ? original(args) : true
  }

// Illustrative traversal of the common cases (named group, array).
// A full implementation also handles tabs, rows, and blocks explicitly.
export function applyFieldAccess(fields: Field[], slug: string, parentPath: string): Field[] {
  return fields.map((field, index) => {
    // Layout wrappers like tabs / unnamed groups are path-transparent:
    // they pass the parent path straight through to their children.
    if (field.type === 'row' || field.type === 'collapsible') {
      return { ...field, fields: applyFieldAccess(field.fields, slug, parentPath) }
    }
    if (!('name' in field) || !field.name) return field // ui fields, etc.

    const path = buildPath(parentPath, index, field.name)
    const wrapped: Field = {
      ...field,
      access: {
        ...field.access,
        read: withFieldRbac('read', slug, path, field.access?.read),
        create: withFieldRbac('create', slug, path, field.access?.create),
        update: withFieldRbac('update', slug, path, field.access?.update),
      },
    }
    if ('fields' in field && Array.isArray(field.fields)) {
      ;(wrapped as { fields: Field[] }).fields = applyFieldAccess(field.fields, slug, path)
    }
    return wrapped
  })
}

The subtle part is path parity. The path you assign here, something like posts.0.title, has to match the exact path the admin UI used when it stored that field’s permission, and the way you collapse layout wrappers has to be identical in both places. Payload stores tab and unnamed-group fields flat, so those wrappers must not consume a path segment, while row and collapsible behave like real nesting. Get the two traversals out of sync and fields silently disappear from results with no error. We keep parity by sharing one traversal helper between the parser that builds the UI tree and the parser that wraps access.

Resolving permissions at request time

Every wrapper calls one resolver. It runs a fixed bypass order, then loads the user’s roles and merges their permission trees into a single effective permission set for the request. The bypass order is: no user means deny, super admin means full bypass, a tenant API key gets read-only, and otherwise resolve from roles.

The hard part is reading the roles. The lookup happens inside an access function, so you can’t use the Local API: payload.find on the roles collection would re-enter the very access wrappers you just installed and recurse forever, and even with access overridden it fires afterRead hooks and relationship population you don’t want in a hot path. Read through the low-level database adapter instead. It returns the rows and nothing else.

// plugin/resolve.ts
import type { PayloadRequest } from 'payload'
import type { CollectionPermission, CrudFlags, Permissions } from '../types'

const CTX_KEY = '__rbacPermissions'

async function loadMergedPermissions(req: PayloadRequest): Promise<Permissions> {
  const userId = req.user!.id

  // db.find bypasses access control + hooks. Pass req to join the transaction.
  const { docs } = await req.payload.db.find({
    collection: 'roles',
    where: { users: { in: [userId] } },
    limit: 100,
    req,
  })

  // A user may hold several roles; union their flags (true wins).
  const merged: Permissions = {}
  for (const role of docs as { permissions?: Permissions }[]) {
    for (const [slug, perm] of Object.entries(role.permissions ?? {})) {
      merged[slug] = unionPermission(merged[slug], perm)
    }
  }
  return merged
}

// Dedupe per request: store the PROMISE so parallel access checks share one read.
export async function resolvePermissions(req: PayloadRequest): Promise<Permissions> {
  if (!req.user) return {}
  const ctx = req.context as Record<string, Promise<Permissions> | undefined>
  if (!ctx[CTX_KEY]) ctx[CTX_KEY] = loadMergedPermissions(req)
  return ctx[CTX_KEY]!
}

export async function resolveCollectionFlags(
  req: PayloadRequest,
  slug: string,
): Promise<CollectionPermission | undefined> {
  const all = await resolvePermissions(req)
  return all[slug]
}

const or = (a: CrudFlags | undefined, b: CrudFlags): CrudFlags => ({
  read: Boolean(a?.read) || b.read,
  create: Boolean(a?.create) || b.create,
  update: Boolean(a?.update) || b.update,
  delete: Boolean(a?.delete) || b.delete,
})

function unionPermission(a: CollectionPermission | undefined, b: CollectionPermission): CollectionPermission {
  const fields: Record<string, CrudFlags> = { ...(a?.fields ?? {}) }
  for (const [path, flags] of Object.entries(b.fields ?? {})) {
    fields[path] = or(fields[path], flags)
  }
  return { ...or(a, b), fields }
}

The req.context memo is the cheap win. Payload fires dozens to hundreds of access checks per request as it walks every collection and field, so without dedup you’d issue the same role read over and over. Caching the in-flight promise collapses all of them into one database round-trip per request, and because req.context dies with the request there’s no staleness to manage. For most apps that single optimization is enough.

When traffic makes even that one read hurt, put Redis in front of it: check the cache, fall back to db.find on a miss, write the result back, and keep it best-effort so a Redis outage degrades to database reads rather than failing requests. Keep it fresh with afterChange and afterDelete hooks on the roles collection that delete the affected users’ cache keys. Use Promise.allSettled for those deletes; the database write has already committed, so a failed cache eviction should never throw out of the hook.

How do multi-tenant roles work?

In multi-tenant mode, roles carry a required tenant relationship and the permission lookup filters by the active tenant, so a role only grants access inside its own tenant. The interesting problem is assignment. Membership lives on the role’s users field, but admins expect to assign a role while editing a user, per tenant. We bridge that with a virtual field and a reconcile hook.

We inject a virtual: true field into each row of the user’s tenants array. A virtual field submits with the form but never persists as a column, which is exactly what we need: it carries the chosen role id into the save without adding a stray database field.

// On save, reconcile the virtual selection into actual role membership.
import type { CollectionAfterChangeHook } from 'payload'

export const reconcileTenantRoles: CollectionAfterChangeHook = async ({ doc, req }) => {
  for (const row of doc.tenants ?? []) {
    const selectedRoleId = (row as { tenantRoles?: string }).tenantRoles
    if (!selectedRoleId) continue

    // Remove this user from any other role in the same tenant, then add the
    // selected one. Go through the Local API (not db) so the role's own
    // conflict guard and cache write-through hooks run.
    // ...diff current membership for (user, tenant)...
    await req.payload.update({
      collection: 'roles',
      id: selectedRoleId,
      data: { users: { /* add user.id to the relationship */ } },
      req, // same transaction
    })
  }
  return doc
}

Going through the Local API here is deliberate, the opposite of the resolver. The resolver needs a side-effect-free read, so it uses the db adapter. Reconciliation needs the role’s own hooks to fire (membership conflict checks, cache invalidation), so it uses payload.update. Same plugin, two different tools, for two different reasons.

This also enforces one role per user per tenant. That rule looks restrictive, but it’s load-bearing: with at most one role per slot, cache invalidation can compute a user’s new permissions for a tenant from the in-transaction diff alone, without an extra read.

Authoring permissions in the admin UI

The role document mounts a tree editor as a ui field. It receives the parsed schema for every collection as client props, renders a collapsible tree of collections and fields with CRUD toggles at each node, and writes each collection’s permission subtree back into the hidden JSON field. The editor enforces the obvious invariants in the UI so bad states never reach the server: enabling create auto-enables read, turning read off disables the rest, and a child write requires the parent’s read. A custom save button diffs the tree and patches only the columns that changed.

We won’t walk the React here; the load-bearing idea is that the same schema parser feeds both this UI and the access wrapping, which is what keeps authored permissions and enforced permissions on identical paths.

Invariants that keep the system correct

A few rules hold the system together, and breaking any of them produces quiet, hard-to-debug failures:

  • Path parity. The UI parser, the access parser, and the permission lookup must produce identical field paths, including how they treat layout wrappers. One shared traversal is the only sane way to guarantee it.
  • Read gates writes. A field or collection can only be created or updated if it can be read. Enforce it both in the UI and in how you flatten permissions, so a create-without-read state can’t exist.
  • RBAC narrows, never widens. Wrappers AND with the original access function. If RBAC grants, the collection’s own access still runs and can restrict further.
  • Cache is best-effort. Redis errors and timeouts degrade to database reads. Permission resolution must never fail a request because a cache was unreachable.
  • Excluded collections keep their original access untouched and never appear in the tree. Use the exclusion list sparingly, and never for roles itself.

Where to take it next

The core of this is smaller than it looks: a roles collection, a config-time pass that wraps collection and field access, and one resolver that reads roles through the db adapter and memoizes on req.context. That alone gives you per-collection and per-field RBAC that any collection author gets for free. Layer the tenant scoping, the tree UI, and Redis on top as you need them. If you’re wiring your own version, keep the Payload access control, database, and Local API docs open, since the boundary between those three is where most of the real decisions live.


Author

Kacper Zawojski

Senior Full-stack Engineer · Payload expert

Kacper is a senior full-stack developer and Payload CMS expert, with a background in algorithmic computer science from Wrocław University of Technology and a master's in Mechatronics and Robotics from Warsaw University of Technology. He has been building production web products at WAYF since early 2024, working across React, TypeScript, Next.js, and Payload — the stack behind most of our client work.


We're booking content platform
engagements for 2026.

Twenty-five minutes to walk through the work and decide if we're the right team for it. Scoping and a fixed price come after.