WABBIT · ESSAYS
Payload access control: what happens to the rules you didn't write
Payload gives every access rule you don't write to anyone who's signed in. On a CMS with five editors that's fine. Add public signup, a member area or a store, and it means strangers. Here's the full list of defaults in Payload 3, how to find the ones your site relies on, and how to close them without breaking anything.
- Tested on
- Payload 3.92.1 (and 3.90), Next.js 16 App Router, MongoDB
- Applies to
- any Payload 3 site where people can create their own account
- Steps
- 10, about 18 min read
- Files you'll touch
- one plugin, one onInit addition, the form-builder options, the jobs config, one collection's field rules, one check script, one test
Every Payload collection has an access block, and tutorials show you how to fill it in: an isAdmin function here, a "user owns this document" query there. Less is written about the keys you leave out. Payload's access control overview covers it in a short paragraph: it says the default "simply checks if a user is present on the request". In the source it's one function, defaultAccess = ({ req: { user } }) => Boolean(user).
That's a sensible default for what Payload started as: a CMS where the only people with accounts are the people editing it. It stops being sensible the day you let visitors sign up. A member area, a store with customer accounts, a course site, a directory where owners claim listings: each of those puts strangers into a users collection, and "anyone signed in" now includes all of them.
We found this on our own site, which allows public signup: a brand-new account could reach operations it never should have. Every rule we'd written was correct. The gap was in the rules we hadn't written. It's fixed on every site we run, and this walkthrough is how we fixed it, rewritten for a generic Payload 3 project.
Steps 1 and 2 show you what the defaults are and which ones your site depends on. Steps 3 to 8 close them: a deny-by-default plugin, the collections Payload adds after it runs, plugin collections, the job endpoints, field rules and the Local API. Steps 9 and 10 keep them closed.
Step 1: Know what every missing rule defaults to
None of the access-control docs pages lists the default per key, so here it is, read from the Payload 3.92.1 source and checked against a running site. "Any signed-in user" means defaultAccess: any account in any auth collection.
| Where | Key | Default when you leave it out |
|---|---|---|
| Any collection | create, read, update, delete | Any signed-in user |
| Any collection | readVersions | Any signed-in user. With no rule, the check returns true whenever req.user is set |
| Auth collection | admin (opening /admin) | Any account in the admin.user collection |
| Auth collection | unlock | Any account in the admin.user collection (since 3.90; before that, any signed-in user) |
| Any global | read, update, readVersions | Any signed-in user |
| Jobs | jobs.access.run (/api/payload-jobs/run, /handle-schedules) | Any signed-in user |
payload-jobs collection | all | Nobody over REST (deny-all since 3.89) |
Form builder: forms | read | Everyone, including guests |
Form builder: forms | create, update, delete | Any signed-in user |
Form builder: form-submissions | create | Everyone, including guests |
Form builder: form-submissions | read | Any account in the admin.user collection |
Form builder: form-submissions | update / delete | Nobody / any signed-in user |
payload-locked-documents | all | Any signed-in user |
| The folders collection | all | Any signed-in user |
payload-jobs-stats global | all | Any signed-in user |
payload-migrations | all | REST is off. The Local API and /api/access use the default |
| Any field | create, read, update | Allowed. A denied update drops the value without an error |
| Local API | overrideAccess | true: no access rules run at all |
Where: Any collection
Key:
create,read,update,deleteDefault when you leave it out: Any signed-in user
Key:
readVersionsDefault when you leave it out: Any signed-in user. With no rule, the check returns
truewheneverreq.useris set
Where: Auth collection
Key:
admin(opening/admin)Default when you leave it out: Any account in the
admin.usercollectionKey:
unlockDefault when you leave it out: Any account in the
admin.usercollection (since 3.90; before that, any signed-in user)
Where: Any global
Key:
read,update,readVersionsDefault when you leave it out: Any signed-in user
Where: Jobs
Key:
jobs.access.run(/api/payload-jobs/run,/handle-schedules)Default when you leave it out: Any signed-in user
Where:
payload-jobscollectionKey: all
Default when you leave it out: Nobody over REST (deny-all since 3.89)
Where: Form builder:
formsKey:
readDefault when you leave it out: Everyone, including guests
Key:
create,update,deleteDefault when you leave it out: Any signed-in user
Where: Form builder:
form-submissionsKey:
createDefault when you leave it out: Everyone, including guests
Key:
readDefault when you leave it out: Any account in the
admin.usercollectionKey:
update/deleteDefault when you leave it out: Nobody / any signed-in user
Where:
payload-locked-documentsKey: all
Default when you leave it out: Any signed-in user
Where: The folders collection
Key: all
Default when you leave it out: Any signed-in user
Where:
payload-jobs-statsglobalKey: all
Default when you leave it out: Any signed-in user
Where:
payload-migrationsKey: all
Default when you leave it out: REST is off. The Local API and
/api/accessuse the default
Where: Any field
Key:
create,read,updateDefault when you leave it out: Allowed. A denied
updatedrops the value without an error
Where: Local API
Key:
overrideAccessDefault when you leave it out:
true: no access rules run at all
Two rows need a second look. The admin and unlock defaults sound narrower than "any signed-in user", and on a site with one users collection they aren't: if your members sign up into the same collection your editors log in with, "any account in the admin.user collection" is everyone. The unlock row only matters if you use Payload's own email and password login; a site on an external auth provider usually has disableLocalStrategy set, and the unlock endpoint refuses before access runs.
The last four collection rows are the ones we hadn't seen covered anywhere, and step 4 is about them.
There's a second kind of default: a rule you did write that means the same thing. Payload's website template sets read: authenticatedOrPublished on Pages and Posts. For a guest, that narrows the query to published documents. For any signed-in user it returns true, so every account, including every customer who signs up, reads every draft and every unpublished page. The helper's name says "published", the file carries no comment, and nothing in the template warns that on a public-signup site "authenticated" means those same visitors. A deny-by-default plugin can't fix this, because the key already has a function in it. It's an easy rule to copy without noticing, and it travels into every project started from the template. The fix is a rule that asks who the user is, not whether there is one:
Guests and non-staff see published; only staff see drafts. It queries _status, so the collection needs drafts enabled.
import type { Access } from 'payload'
export const staffOrPublished =
(staff: Access): Access =>
async (args) => {
const published = {
_status: { equals: 'published' },
}
if (!args.req.user) return published
const ok = await staff(args)
if (ok === true) return true
if (ok && typeof ok === 'object') {
return { or: [ok, published] }
}
return published
}Only an explicit true from your staff check opens drafts. If the check returns a query of its own ("documents I created"), it's combined with the published filter, so a scoped editor sees their drafts plus everything published. Search your config for authenticatedOrPublished, and for any read rule that returns true as soon as req.user exists, and replace each one. Step 9's guard catches the next one.
Step 2: Find out which defaults your site relies on
Before changing anything, list every operation on your site that has no rule. On a real config the list is longer than you'd guess, because plugins and packages add collections you never wrote an access block for.
The plugin in step 3 has a report-only mode for this. It walks the final config, records every missing rule and changes nothing. Its output looks like this:
Read this before you enforce anything. Every line is an operation with no rule of its own.
[denyByDefault] 87 operations have no rule:
pages: readVersions
posts: readVersions
media: readVersions
users: readVersions, unlock
orders: create, update, delete, readVersions
forms: readVersions
form-submissions: readVersions
global header: readVersions
global footer: readVersions
jobs: run, queue, cancel
...The jobs line needs a footnote. run is the endpoint rule from the table. queue and cancel are consulted only by the Local API, and only when a caller passes overrideAccess: false; in Payload 3 their fallback is () => true. Filling them costs nothing and matters in 4.0, where that becomes the default. That sample is trimmed. On a larger app the list runs past a hundred lines, and most of them are version history.
Go down the list and ask one question per line: should a stranger with an account be able to do this? Version history is a no almost everywhere, because it exposes drafts. Writes on content are a no. Anything that should stay open, like members creating their own reviews or reading their own orders, needs a rule you write yourself, before step 3 makes it staff-only.
Step 3: Deny by default, as the last plugin
The fix is a plugin that runs after every other plugin, finds each missing rule and fills it with your staff check. Rules you wrote are never touched. Here's a complete version:
Fills every missing rule with staff. Rules you wrote are left alone.
import type {
Access,
CollectionConfig,
Config,
Plugin,
} from 'payload'
type Rules = Record<string, unknown>
export type Filled = { where: string; op: string }
const DOC_OPS = [
'create',
'read',
'update',
'delete',
'readVersions',
]
const AUTH_DOC_OPS = [...DOC_OPS, 'admin', 'unlock']
const GLOBAL_OPS = ['read', 'update', 'readVersions']
const JOB_OPS = ['run', 'queue', 'cancel']
// Where a query result means nothing (the admin
// panel, the job queue), only `true` opens.
const strict =
(staff: Access): Access =>
async (args) => (await staff(args)) === true
function fill(
rules: Rules | undefined,
ops: string[],
rule: (op: string) => unknown,
where: string,
filled: Filled[],
): Rules {
const next: Rules = { ...rules }
for (const op of ops) {
if (typeof next[op] === 'function') continue
next[op] = rule(op)
filled.push({ where, op })
}
return next
}
export const denyByDefault =
(options: {
staff: Access
reportOnly?: boolean
onReport?: (filled: Filled[]) => void
}): Plugin =>
(config: Config): Config => {
const { staff, reportOnly, onReport } = options
const filled: Filled[] = []
const pick = (op: string) =>
op === 'admin' ? strict(staff) : staff
const collections = (config.collections ?? [])
.map((c: CollectionConfig) => ({
...c,
access: fill(
c.access as Rules,
c.auth ? AUTH_DOC_OPS : DOC_OPS,
pick,
c.slug,
filled,
),
}))
const globals = (config.globals ?? [])
.map((g) => ({
...g,
access: fill(
g.access as Rules,
GLOBAL_OPS,
pick,
`global ${g.slug}`,
filled,
),
}))
const jobs = {
...config.jobs,
access: fill(
config.jobs?.access as Rules,
JOB_OPS,
() => strict(staff),
'jobs',
filled,
),
}
onReport?.(filled)
if (reportOnly) return config
return {
...config,
collections,
globals,
jobs,
} as Config
}Add it at the end of your plugins array, first with reportOnly: true and an onReport that logs the list:
Last in the array. Plugins added after it would add collections it never saw.
import { denyByDefault } from
'./access/denyByDefault'
import { staff } from './access/staff'
export default buildConfig({
// ...
plugins: [
formBuilderPlugin({ /* ... */ }),
seoPlugin({ /* ... */ }),
// MUST stay last.
denyByDefault({ staff }),
],
})A few choices in that code are deliberate.
It runs last because plugins add collections. Form builder adds forms and form-submissions, the search plugin adds search, redirects adds redirects. A plugin that runs before them can't see them. If someone later appends a plugin after it, that plugin's collections are back on the default, which is why step 9 adds a test for the position. Payload 3 also sorts plugins by their order property before running them, so if any plugin in your config sets order, give this one a larger value; otherwise array position is what counts.
admin and the job queue only take true. Collection rules can return a query ({ author: { equals: user.id } }), and Payload uses it to filter documents. The admin panel and the job endpoints aren't documents, so a query result there means nothing, and a staff check that returns one shouldn't open them. strict turns anything other than true into false.
Anything with a function is left alone. If you wrote read: () => true on pages, pages stay public. The plugin only fills gaps, so turning it on can't loosen anything you wrote. It can make something stricter than you intended, which is what the report in step 2 is for.
staff is your own check. Make it a role check that excludes customers, not Boolean(req.user). On a site with public signup, "signed in" isn't a role.
After you switch from report-only to enforcing, write the rules for what should stay open. Usually that's read: () => true (or a published-only query) on public content, and member-scoped rules for anything members manage themselves. The users collection is the one every public-signup site hits first: after the plugin, a member can't read their own account over REST or through a server action that runs as them. Give it a rule that admits the account itself or staff:
A member reads their own record; staff read all; guests nothing.
read: async (args) => {
if (!args.req.user) return false
if ((await staff(args)) === true) return true
return { id: { equals: args.req.user.id } }
},Step 4: Cover the collections Payload adds after your plugins
This is the part a deny-by-default plugin misses on its own. Payload builds a few collections itself in its final config step, after every plugin has run:
payload-locked-documents, which records who has a document open for editing;payload-migrations;- the folders collection, when any collection turns on folders;
- the
payload-jobs-statsglobal, when any task or workflow has a schedule.
Each of these uses defaultAccess on every operation, and none passes through your plugins. On a stock config, a fresh customer account can list every edit lock on the site, create a lock on a page an editor is about to open, and delete locks. It doesn't leak content, but it tells a stranger who's editing what, and it lets them get in an editor's way.
Payload has no override hook for locked documents or migrations, so set their rules once Payload has built them. payload.collections[slug].config is the same object Payload checks access against, so changing it in onInit takes effect for every request after that:
Runs at init, after Payload has built its own collections.
import type { Access, Payload } from 'payload'
const LOCKS = 'payload-locked-documents'
const LATE = [
LOCKS,
'payload-migrations',
] as const
const OPS = [
'create',
'read',
'update',
'delete',
'readVersions',
]
// Payload's admin reads locks as the signed-in
// user. Whoever can open /admin must be able to
// read them, or the edit view breaks.
const canOpenAdmin =
(staff: Access): Access =>
async (args) => {
const { user, payload } = args.req
if (!user) return false
const adminSlug = payload.config.admin.user
if (user.collection !== adminSlug) return false
const rule = payload.collections[adminSlug]
?.config.access?.admin
const ok = rule
? await rule(args)
: await staff(args)
return ok === true
}
export function lockLateCollections(
payload: Payload,
staff: Access,
) {
for (const slug of LATE) {
const collection = payload.collections[slug]
if (!collection) continue
const access: Record<string, Access> = {}
for (const op of OPS) {
const lockRead =
slug === LOCKS &&
(op === 'read' || op === 'readVersions')
access[op] = lockRead
? canOpenAdmin(staff)
: staff
}
Object.assign(collection.config.access, access)
}
}Then call it from the plugin's onInit, before the site's own:
Add to the returned config. Report-only mode skips it.
// in denyByDefault, on the returned config
onInit: async (payload) => {
if (!reportOnly) {
lockLateCollections(payload, staff)
}
await config.onInit?.(payload)
},The read rule on locks is the one place we didn't use staff. Payload's admin reads lock records as the signed-in user, with access applied, every time it opens a document. Writes go straight to the database. So anyone who's allowed into /admin, including a read-only reviewer role if you have one, needs to read locks or their edit view breaks. Everyone else gets nothing. canOpenAdmin is a little narrower than Payload's own admin check, which also admits a user from a second auth collection whose own access.admin says yes; if you run two auth collections, mirror that.
The folders collection does have an override hook. Set its access through folders.collectionOverrides in your config, the same way you'd override any plugin collection. The jobs-stats global only exists once you schedule a task. If you do, give it the same treatment in onInit by finding it in payload.globals.config.
To check it worked, sign in as a customer and request /api/payload-locked-documents. You want a 403.
Two simpler routes exist. If your site doesn't use the admin's edit locks, set lockDocuments: false on every collection and global and Payload never creates the locks collection at all. For migrations, an upstream pull request proposes denying the collection outright; until it lands, the onInit rule is the bridge.
Step 5: Plugin collections: the form builder
Plugins ship their own access rules, and they're written for the plugin's typical use, not your signup flow. The form builder is a good example because it's on most Payload sites and its collection holds personal data.
Its form-submissions collection allows create for everyone (deliberately: it's what a public contact form posts to), sets read to any account in the admin.user collection, sets update to nobody and has no delete rule. On a site where customers sign up into that collection, every customer can read every submission and delete any of them. Its forms collection makes read public and leaves create, update and delete to the default.
Both collections take your rules through the plugin options, and the plugin merges them over its own:
Your keys win. Anything you leave out keeps the plugin's rule.
formBuilderPlugin({
formOverrides: {
access: {
read: () => true,
create: staff,
update: staff,
delete: staff,
},
},
formSubmissionOverrides: {
access: {
read: staff,
delete: staff,
},
},
}),The deny-by-default plugin from step 3 can't fix form-submissions read for you. The plugin wrote a function for it, so it counts as a rule you have, even though it admits everyone. That's why the report is worth reading line by line, and why a plugin's own rules need checking against your signup model one by one. If you want the spam side of this collection covered too, our Payload form builder tutorial handles it.
Step 6: Lock the job endpoints
/api/payload-jobs/run and /api/payload-jobs/handle-schedules run your queued jobs. Their access is jobs.access.run, and when you don't set it, it's the default: any signed-in user. On a public-signup site, that's any customer triggering your job queue on demand.
Payload's website template sets this rule, and it's a good starting point for the site it was written for:
Fine for a site with no public signup. On yours, the first line lets every customer in.
run: ({ req }) => {
// Allow logged in users to execute
// this endpoint (default)
if (req.user) return true
const secret = process.env.CRON_SECRET
if (!secret) return false
const authHeader =
req.headers.get('authorization')
return authHeader === `Bearer ${secret}`
},The template's own users collection doesn't allow signup, so "logged in" there means an editor. Copy it into a site with customer accounts and the first line lets every customer run jobs.
If your project started from an older copy of the template, check for a second problem. Older versions didn't have the if (!secret) return false line, so with CRON_SECRET unset the comparison became `Bearer undefined` and a request sending exactly that header got through. Search your config for CRON_SECRET and make sure a missing secret refuses.
Here's the rule we use instead: staff, or a scheduler that sends the secret, compared in constant time and refused when the secret isn't set.
Staff, or the cron bearer. A blank secret never matches a blank header.
import {
createHash,
timingSafeEqual,
} from 'node:crypto'
import type { Access, PayloadRequest } from 'payload'
const digest = (value: string) =>
createHash('sha256').update(value).digest()
const sameValue = (a: string, b: string) =>
timingSafeEqual(digest(a), digest(b))
export const jobsRun =
(staff: Access) =>
async ({ req }: { req: PayloadRequest }) => {
if (
req.user &&
(await staff({ req })) === true
) {
return true
}
const secret = process.env.CRON_SECRET
if (!secret) return false
const header =
req.headers.get('authorization') ?? ''
return sameValue(header, `Bearer ${secret}`)
}queue and cancel fall to denyByDefault, which gives them staff; Payload 3 checks them only on Local API calls with overrideAccess: false.
jobs: {
access: { run: jobsRun(staff) },
tasks: [/* ... */],
},Hashing both sides first gives timingSafeEqual two buffers of the same length, so the comparison doesn't leak the secret's length either. Since 3.89 the payload-jobs collection itself denies every REST operation by default, so the endpoints are the part to cover.
Step 7: Field rules fail quietly
Field-level access looks like a collection rule, but a denied field behaves differently. When a field's access.update returns false, Payload doesn't throw. It keeps the stored value and saves the rest of the request. The field access docs say so, and it's easy to miss: the caller gets a 200 and the document they asked for, minus the change.
That's fine for keeping members out of a role field. It's a problem when the field decides who a document belongs to. Take reviews on a directory site, where members write their own reviews and edit them while they're pending. If author and listing can change after create, a member can write a review for a competitor's listing and move it to their own. A field rule alone stops the change silently, and the member never learns the request failed, which makes the next attempt cheap.
We use two layers, plus a create-time rule. The field rule stops the value from being saved. A wrapper around the collection's update rule refuses the whole request when someone tries to change an identity field, so the attempt is loud and nothing is half-applied:
Admins and trusted writes may change identity fields; nobody else can.
import { Forbidden } from 'payload'
import type {
Access,
CollectionBeforeValidateHook,
FieldAccess,
PayloadRequest,
} from 'payload'
import { isAdmin } from '@/access/isAdmin'
const IDENTITY = ['author', 'listing']
const idOf = (v: unknown) =>
v && typeof v === 'object' && 'id' in v
? String(v.id)
: v == null ? null : String(v)
const trusted = async (req: PayloadRequest) =>
req.context?.trustedWrite === true ||
(await isAdmin({ req })) === true
// Layer 1: the value is kept, silently.
export const identityField: FieldAccess =
({ req }) => trusted(req)
// Layer 2: refuse the request, loudly.
export const guardUpdate =
(base: Access): Access =>
async (args) => {
const ok = await base(args)
if (!ok || (await trusted(args.req))) {
return ok
}
const data = (args.data ?? {}) as
Record<string, unknown>
const touched = IDENTITY.filter(
(f) => f in data,
)
if (touched.length === 0) return ok
if (args.id == null) return false
const doc = await args.req.payload.findByID({
collection: 'reviews',
id: args.id,
depth: 0,
overrideAccess: true,
})
const stored: Record<string, unknown> = {
...doc,
}
const same = touched.every(
(f) => idOf(data[f]) === idOf(stored[f]),
)
return same ? ok : false
}
// On create, the author is whoever is signed in.
export const forceAuthor:
CollectionBeforeValidateHook = async ({
data,
req,
operation,
}) => {
if (operation !== 'create' || !data) {
return data
}
if (await trusted(req)) return data
if (!req.user) throw new Forbidden()
return { ...data, author: req.user.id }
}forceAuthor goes first, so later hooks see the real author.
access: {
create: membersOnly,
read: publishedOrOwn,
update: guardUpdate(ownPendingReview),
delete: ownPendingReview,
},
hooks: {
beforeValidate: [forceAuthor],
},
fields: [
{
name: 'author',
type: 'relationship',
relationTo: 'users',
access: { update: identityField },
},
// listing: same field rule
],Three details matter here. The update guard allows an exact echo of the stored value, because admin forms and many clients send the whole document back. A bulk update by query can never change an identity field, because there's no single document to compare against. And forceAuthor overwrites the author on create instead of trusting what the client sent; otherwise a member can post a review attributed to someone else by sending another member's id. The guard also runs for the admin's permission probes, where data is empty, so the early return on an untouched identity field is what keeps those probes cheap; don't remove it.
req.context.trustedWrite is how the site's own code gets past the guard (name it anything): a moderation tool or a seed script passes context: { trustedWrite: true } on its Local API call. Nothing a client sends over REST can set req.context.
Step 8: The Local API skips every rule
payload.find, payload.update and the rest run with overrideAccess: true unless you say otherwise. That's right for system work: a seed script, a webhook handler, a scheduled job. It's wrong whenever the call acts for a visitor, like a server action that loads "my orders" or saves a profile form.
The trap is that passing user isn't enough:
Passing user sets req.user. It doesn't turn access on.
// Runs every rule as the visitor.
const orders = await payload.find({
collection: 'orders',
user,
overrideAccess: false,
})
// Skips every rule. `user` alone changes
// nothing about access in Payload 3.
await payload.update({
collection: 'reviews',
id,
data,
user,
})When a call acts for someone, pass both user and overrideAccess: false. When it's system work, pass overrideAccess: true explicitly and say why in a comment (it's also what the 4.0 codemod inserts), so the next person doesn't "fix" it. A grep for Local API calls without overrideAccess: false inside server actions and route handlers is a quick audit.
Payload 4.0 changes this, and several other defaults. From the 4.0 beta and its migration notes:
- The Local API's
overrideAccessdefaults tofalse. Calls that relied on the old default start running access rules, with no compile error to point you at them. Payload ships a codemod, and the migration guide lists what it leaves behind: spread arguments, aliased receivers and non-literal options (issue #18874 tracks it), so read those call sites by hand. - An unset
readVersionsinheritsread, on collections and on globals. If your content hasread: () => true, version history and drafts become public unless you write areadVersionsrule. defaultAccessnarrows to accounts in theadmin.usercollection (PR #16906). On a site whose members sign up into that collection, it's still everyone.- A new
validatekey joins the access block and defaults toupdate.
The plugin from step 3 doesn't need changes for 4.0 except one: add validate to its list of document operations once you upgrade, so it's filled like the rest.
Step 9: Check for new gaps in CI
The plugin closes today's gaps. Next month someone adds a collection, or installs a plugin, and its missing rules go to staff without anyone deciding that. Usually that's what you'd want. Sometimes it's a feature members were meant to use, and you find out from a support email.
Turn each new staff-only default into a reviewed decision. Record what the plugin filled, commit the list, and fail the build when a line appears that isn't in it. First, store the report where a script can read it:
Stores the report on a global because the config is built at import time, before any module of yours could be asked for it. Costs nothing at runtime.
denyByDefault({
staff,
onReport: (filled) => {
Reflect.set(globalThis, 'accessReport', filled)
},
}),Then a script that builds the config, which needs no database connection, and compares:
Fails on a new line. --update accepts the current list.
import { readFileSync, writeFileSync } from 'node:fs'
import type { Filled } from
'../src/access/denyByDefault'
await (await import('../src/payload.config'))
.default
const report = (Reflect.get(
globalThis,
'accessReport',
) ?? []) as Filled[]
const lines = report
.map((f) => `${f.where}: ${f.op}`)
.sort()
const file = 'access-defaults.json'
if (process.argv.includes('--update')) {
writeFileSync(
file,
JSON.stringify(lines, null, 2) + '\n',
)
process.exit(0)
}
const known = new Set<string>(
JSON.parse(readFileSync(file, 'utf8')),
)
const added = lines.filter((l) => !known.has(l))
if (added.length > 0) {
console.error('No rule, now staff-only:')
for (const l of added) console.error(` ${l}`)
console.error(
'Write a rule, or accept with --update.',
)
process.exit(1)
}Run it with whatever loads your TypeScript and path aliases (tsx works), with throwaway values for any environment variables your config validates. It doesn't connect to anything. When it fails, there are two right answers: write the rule the new collection should have, or run it with --update and commit the new list, which records that staff-only was a decision. Lines that disappear because someone wrote a rule are fine and don't fail the check.
Add a second, cheaper test that reads your config files as text. It catches the two mistakes that undo all of this: using a "signed in" check as a collection rule, and moving the plugin off the end of the array.
Static checks. They read files; nothing is imported or run.
import { globSync, readFileSync } from 'node:fs'
import { expect, it } from 'vitest'
const SIGNED_IN_ONLY =
/\b(create|read|update|delete|readVersions|admin)\s*:\s*(authenticated|authenticatedOrPublished|isLoggedIn)\b/
it('no collection rule is "any signed-in user"',
() => {
const hits = globSync('src/collections/**/*.ts')
.flatMap((file) =>
readFileSync(file, 'utf8')
.split('\n')
.filter((l) => SIGNED_IN_ONLY.test(l))
.filter((l) => !l.includes('signed-in: ok'))
.map((l) => `${file}: ${l.trim()}`),
)
expect(hits).toEqual([])
})
it('denyByDefault is the last plugin', () => {
const config = readFileSync(
'src/payload.config.ts',
'utf8',
)
const start = config.indexOf('plugins: [')
const body = config.slice(
start,
config.indexOf('\n ],', start),
)
const last = body
.split('\n')
.map((l) => l.trim())
.filter((l) => l && !l.startsWith('//'))
.pop()
expect(last).toMatch(/^denyByDefault\(/)
})Rename authenticated and isLoggedIn to whatever your project calls its "has a session" helper. globSync from node:fs needs Node 22.17 or later (it was experimental from 22.0). When a rule really should admit any account, like members creating their own posts, end the line with a // signed-in: ok comment and the reason. The test skips it, and the reason is there for whoever reviews the change. The plugin-last test finds the end of the array by its two-space indent; if your formatter differs, anchor it on a sentinel comment such as // MUST stay last. instead. The regex is the one line in this walkthrough that runs past the code width; leave it on one line so it stays searchable.
Step 10: Probe the live site
Tests check your config. A probe checks what the server actually does, after the build, the deploy and the environment variables. Do it twice: once as a guest, and once as a brand-new account on a staging copy of the site.
As a guest, against production:
Replace the host. Each line prints the status code.
SITE=https://example.com
for p in \
/api/payload-locked-documents \
/api/form-submissions \
"/api/pages/versions?limit=1" \
/api/payload-jobs/run \
"/api/pages?limit=1"; do
printf '%-36s ' "$p"
curl -s -o /dev/null -w '%{http_code}\n' \
"$SITE$p"
doneYou want 403 on the first three, 401 on the job endpoint (404 if the site has no tasks or workflows, because the endpoint doesn't exist), and 200 on the public pages. Add one more request with -H 'Authorization: Bearer undefined' against the job endpoint; it must not return 200.
The guest probe can't show the defaults from step 1, because guests were never "signed in". For that, sign up on your staging site the way a visitor would, copy the session cookie from your browser's developer tools, and run the same loop with -b "name=value" added to the curl line. Add /api/payload-locked-documents with a POST and /api/forms with a POST. A customer should get 403 on all of them and 200 on public pages. Delete the test account afterwards.
Don't create test accounts on production. If staging and production run the same build, the staging probe answers the question, and the guest probe on production confirms the deploy.
Common mistakes
Using "signed in" as a role. Boolean(req.user) on a collection rule reads like "staff only" and means "anyone with an account". The day signup opens, every one of those rules opens with it.
Writing explicit rules for everything except readVersions. It's the key most often left out, it falls to the default, and it exposes drafts and every past version of a document.
Adding a plugin after the deny-by-default plugin. Its collections are back on Payload's defaults, and nothing tells you. Keep a test on the position.
Copying the template's authenticatedOrPublished. It reads as "published, unless you're signed in" and means "drafts for everyone with an account". On a site where visitors can sign up, replace it with the staff-or-published rule from step 1.
Trusting a plugin's rules. A plugin's read that admits "any account in the admin collection" is a written rule, so a deny-by-default plugin won't replace it. Read each plugin collection's access against your signup model.
Treating a 200 as success on a field update. A denied field update returns the document without the change. If the field matters, refuse the request in the collection rule.
Passing user to the Local API and stopping there. Without overrideAccess: false, Payload 3 runs no rules at all.
Probing only as a guest. Every default in this walkthrough applies to signed-in users. A guest probe shows a clean result on a site that's wide open to its own customers.
How Tome does it
Tome, our Payload platform, ships this as part of its core package: a deny-by-default plugin with a report mode, a matching rule for the job endpoints, the onInit cover for the collections Payload adds late, including the read split on edit locks, and a staffOrPublished rule for drafts-enabled collections. New sites are audited in report mode before enforcing. Tome Starter, the template new Tome sites begin from, also ships the CI check from step 9 with a committed list of what's staff-only, so a new collection can't change access without someone deciding it.
The code in this walkthrough was written for this post. You can use it in your own project.
Checklist
- Every collection and global on the site has been listed in report mode, and each missing rule has a decision.
- A deny-by-default plugin is last in the plugins array, with a test that keeps it there.
- Public content has an explicit
readrule,readVersionsis staff-only, and no read rule on a drafts-enabled collection returnstruejust becausereq.useris set. payload-locked-documentsandpayload-migrationsget rules at init, lock reads go to admin-panel users, and folders are covered if you use them.form-submissionsreadanddeleteare staff-only, andformswrites are staff-only.jobs.access.runadmits staff or a constant-time bearer token, and refuses when the secret is unset.- Fields that decide ownership are guarded at the collection level, and authors are set from
req.useron create. - Local API calls that act for a visitor pass
userandoverrideAccess: false. - CI fails when a new operation falls back to staff-only.
- The live site has been probed as a guest on production and as a fresh account on staging.
FAQ
What is Payload's default access control?
When you leave an access key out, Payload uses defaultAccess, which allows any request with a signed-in user and denies guests. That applies to create, read, update, delete and readVersions on collections, to globals and to the job run endpoints. admin and unlock default to any account in the admin.user collection.
Is Payload access control safe with public signup?
Not by default. Once visitors can create accounts, "any signed-in user" includes them, so every key you didn't write is open to strangers. Write explicit rules or add a deny-by-default plugin as the last plugin, and cover payload-locked-documents, which Payload adds after plugins run.
Is Payload's authenticatedOrPublished safe to use?
Only on a site where every account belongs to an editor. It returns true for any signed-in user, so with public signup every customer can read every draft over REST. Use a rule that returns true only for staff and narrows everyone else, signed in or not, to _status: published.
Does the Payload Local API check access?
Not in Payload 3 unless you pass overrideAccess: false. Passing user on its own sets the user on the request but still skips every access rule. In Payload 4.0, overrideAccess defaults to false.
Why does a Payload field update return 200 without saving?
Field-level access.update that returns false drops that field's value and saves the rest of the request without an error. If the field matters, also refuse the request in the collection's update rule when the field changes.
Who can run Payload jobs through the REST API?
/api/payload-jobs/run and /handle-schedules use jobs.access.run, which defaults to any signed-in user. Set it to your staff check plus a secret bearer token compared in constant time, and refuse when the secret isn't set.
Who can read Payload form-builder submissions?
By default, any account in the admin.user collection can read them, and with no delete rule any signed-in user can delete them. Set formSubmissionOverrides.access to your staff check for read and delete.