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.

  • Engineering
  • Practice
  • Product

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.

If you’re weighing this from the buying side rather than the building side — who can change what, how approvals work, what an audit needs to show — the plain-language version of this problem is in Content governance in a headless CMS. This post is the engineering deep-dive under it.

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.

How do you prove the policy holds?

Three things get conflated when someone says a role “works”: what the admin panel draws, who the request says you are, and what the server will let you do. Only the third is access control. A field the panel declines to render is still there in the API response unless an access function took it out, and the easy way to be wrong about your own RBAC is to log in as a test user, see a tidy screen, and stop.

So check it the way an attacker would: through the API, as each actor, asserting what came back rather than what a status code said. The table below is the shape we use. Copy it, put your own collections and roles in it, and leave the last column honest — not tested is a legitimate answer and a much better one than an assumption.

Actor and tenantOperation and entry pointExpectedHow verifiedResult
AnonymousRead and create, Local API with overrideAccess: falseRefused, nothing writtenAssert the rejection, then re-count the collectionRefused. Read throws Forbidden 403
Permitted editor, tenant ARead, and update an allowed field, on their own documentAllowed and persistedRead the value back from storageAllowed
Editor, tenant AThe collection action their role lacks, here deleteRefused, document survivesAssert the rejection, then re-read the documentRefused
Editor, tenant ARead a restricted fieldField absent from the responseAssert the key is undefined, not merely unrenderedAbsent
Editor, tenant AWrite a restricted fieldStored value unchangedRe-read with access overridden and compareUnchanged
Editor, tenant ADirect id lookup into tenant B, and a list queryNeither returns tenant BAssert on the returned documents, not the countRefused
Editor whose role was just revokedThe same update, next requestRefusedRe-read the user, then retry the writeRefused
Same editorThe same read with the Local API defaultAccess bypassedCall without overrideAccess: falseReturns tenant B’s document and the restricted field
Admin, and a viewer for contrastRead the restricted fieldAdmin yes, viewer noCompare both responsesAs expected
Editor, tenant ACreate in own tenant, including a restricted fieldPost created, restricted field absentRead the stored documentAs expected
Editor, tenant ACreate in tenant B, or move own document to tenant BNo foreign insert or reassignmentRe-count documents and re-read the stored tenantRefused or stripped
Editor, tenant AUpdate tenant B’s document directlyRefused, body unchangedRe-read with access overriddenUnchanged

Download the complete runnable fixture, including the config, seed, tests and dependency lockfile. Unzip it, enter examples/payload-rbac-verification, and follow the README using Node 24.19.0, npm ci, npm run seed and npm run verify. It uses fictional data in a local SQLite file; it does not connect to a production system.

The excerpt below shows the read boundary. The scoping is returned as a Where rather than a boolean, which is what makes it hold on a direct findByID as well as on a list query:

const withinOwnTenant = ({ req: { user } }) => {
  if (!user) return false
  if (user.role === 'admin') return true
  const tenant = typeof user.tenant === 'object' ? user.tenant?.id : user.tenant
  return tenant ? { tenant: { equals: tenant } } : false
}

// The full config and other access functions are in the download.
// posts.access.read: withinOwnTenant
// internalNotes.access: { read: isAdmin, create: isAdmin, update: isAdmin }

For writes, test two separate boundaries: create access returns a boolean after checking the incoming tenant, while update access filters the existing document and the tenant field has its own update gate. A read filter alone does not prevent a cross-tenant insert or reassignment. The review tests reproduced both mistakes in the first fixture and passed after those rules were corrected.

The fixture seeds two tenants and four users across three roles, then asserts against storage rather than only a status code. This pseudocode shows the re-read pattern; tryUpdate stands for the guarded update attempt in the downloadable tests:

const before = await payload.findByID({ collection: 'posts', id })
await tryUpdate({ user: editor, data: { internalNotes: 'should not land' } })
const after = await payload.findByID({ collection: 'posts', id })
assert.equal(after.internalNotes, before.internalNotes)

Whether Payload throws or silently strips the field, that assertion holds the line. Asserting only on the rejection would miss a strip, and asserting only on the response would miss a write that landed anyway.

The downloadable fixture has sixteen tests. All sixteen passed on Payload 3.89.0 and Node v24.11.0 on 14 September 2026. The same tests passed again on Payload 3.90.2, SQLite adapter 3.90.2 and Node v24.19.0 on 26 September. The download now pins those updated packages; the README retains both run records. It is not this plugin. It hand-writes three fixed roles as ordinary access functions, because the boundary being demonstrated sits underneath any role design, permission tree or not.

The refresh follows Payload’s 18 September security update. The migration notes cover changes that existing applications must review, including an added authentication field. Here, the seed rebuilds a disposable database, and the regenerated types and schema include that field. These checks cover the fixture’s Local API policy; application migrations and the other advisory-specific paths need their own verification.

Two results from that run are worth carrying into your own code.

A denied read arrives as a 403. When collection read access returns false outright, Payload throws Forbidden and hands back no result set at all. Client code that reads “zero documents” as the denial signal will sail straight past it.

The Local API bypasses access control unless you opt in. Payload’s own Local API access-control docs say local operations override access control by default, and that respecting it means passing the user and setting overrideAccess: false. The fixture shows the size of that default: the same caller who is correctly confined to one tenant with the flag set reads another tenant’s document, restricted field included, without it. Every Local API call you make from a server action, a hook or a scheduled job sits on the wrong side of that default until you say otherwise, and correct role modelling does not save you from it.

What a green run does not tell you is worth stating too. It says this fixture’s policy holds under these assertions. It says nothing about a production deployment, nothing about REST or GraphQL authentication, which carry cookies and tokens this never touches, and nothing about a JWT issued before a role changed and still carrying the old one. That last one belongs to your session strategy rather than your access control, and it is where a revocation quietly fails to take effect.

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. For the publishing side, with drafts, an approval step and rollback tested over REST with API keys, see A Payload CMS approval workflow for AI-written drafts.

FAQ

  1. Does Payload CMS support role-based access control?

    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. Roles that carry permissions down to individual fields are what the plugin described here adds: a roles collection where each role holds a permission tree, wrapping every collection and field access function so reads, writes, creates, and deletes are gated by the acting user's roles.

  2. Does Payload support field-level access control?

    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, so within posts a role can read and update title but not even read internalNotes.

  3. How do you model roles and permissions in Payload CMS?

    Each role holds a permission tree in a roles collection. Administrators toggle CRUD flags per collection and per field, and the plugin resolves a user's effective permissions on each request and enforces them. A role can read posts, update posts, but not delete them. Super admins skip all checks, and users can always read and update their own user document.

  4. Do you have to rewrite collection access functions to add RBAC?

    No. None of this changes how collection authors write their own access functions. At boot the plugin maps over every collection and replaces its access functions with wrappers that check the role permissions first and defer to the original function second. The wrappers AND with the original access function, so where RBAC grants, the collection's own access still runs and can restrict further. Because the wiring happens once at startup, the only per-request cost is the permission lookup itself.

  5. How do multi-tenant roles work in Payload?

    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. Membership lives on the role's users field, while admins expect to assign a role while editing a user, per tenant. The plugin bridges that with a virtual field injected into each row of the user's tenants array, which submits with the form without persisting as a column, and a reconcile hook that turns the chosen role id into actual role membership on save.


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.


Where to go next WAYF is an official Payload partner and built the role-based access control described here.

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.