payload-cms-security
Skill16 starsChanged 6 months agoMITView on GitHub
Harden Payload CMS deployments against access-control and upload-related issues. Covers collection and field-level access functions, hook safety, file upload validation, GraphQL and REST surface, admin UI exposure, and multi-tenant isolation strategies. Invoke before shipping a Payload app to production, opening admin to non-developers, or after a Payload version upgrade.
- Reads credentials
In this repository37 other files it gives its agents.
- agent-client-securitySkill
- ai-agent-guardrailsSkill
- android-securitySkill
- api-securitySkill
- auth-hardeningSkill
- backend-architectureSkill
- backup-disaster-recoverySkill
- cloudflare-hardeningSkill
- codebase-auditSkill
- dach-complianceSkill
- dependency-supply-chainSkill
- distributed-system-auditSkill
- dns-domain-securitySkill
- docker-container-securitySkill
- email-deliverability-securitySkill
- file-upload-securitySkill
- gdpr-technical-controlsSkill
- github-actions-securitySkill
- honeypot-tarpitsSkill
- incident-responseSkill
- ios-securitySkill
- kubernetes-securitySkill
- llm-app-securitySkill
- llm-coding-failure-modesSkill
- log-strategySkill
- mcp-securitySkill
- message-bus-securitySkill
- nextjs-securitySkill
- object-storage-securitySkill
- postgres-hardeningSkill
- prompt-injection-defenseSkill
- rag-securitySkill
- secret-hygieneSkill
- site-server-auditSkill
- stripe-webhook-securitySkill
- vps-hardeningSkill
- wordpress-hardeningSkill
The file
We split this skill into the rules it gives an agent
5 rules. About 0 are common, 3 are distinctive, and 2 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈3
Distinctive · estimate
This author's own choices. Worth a look.
2
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Distinctive: line 14
After a Payload major version upgrade (v2 → v3)
- Very specific: line 40
Every collection has access functions that decide who can create, read, update, delete.
- Very specific: line 70
Always check
userfor null: before reading properties. - Distinctive: line 213
If one Payload instance serves multiple tenants (common SaaS pattern), tenant isolation is a top concern.
- Distinctive: line 234
Test upgrades on staging before prod
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Payload CMS Security
Payload (v2 / v3) gives you a powerful headless CMS in a Node app, but its security depends almost entirely on the access functions you write per collection and per field. Defaults are reasonable but not strict — production-readiness requires intentional config.
When to invoke
- Shipping a Payload app to production
- Opening the admin UI to non-developer team members or clients
- After a Payload major version upgrade (v2 → v3)
- Adding a new collection, especially user-generated or multi-tenant data
- Investigating a "user X saw user Y's data" incident
Step 1 — Lock the admin UI
By default the admin is at /admin on the same origin as the app. That is a permanent target.
Three layers of defense, use at least two:
- Zero Trust / IP allowlist in front — put
/adminbehind SSO with Cloudflare Access, Tailscale, or a VPN. The admin should not be reachable from the open internet for most projects. Seecloudflare-hardening. - Rate limit auth endpoints —
/api/users/loginand friends. Cloudflare Rate Limit orexpress-rate-limiton the Express app. - Strong auth for admin users — long passphrases, MFA where supported (Payload v3 has plugins for this), no shared accounts.
Optional but worth it: move /admin to an unguessable path via routes.admin. Not strong security, but cuts noise:
// payload.config.ts
export default buildConfig({
routes: { admin: '/cms-' + process.env.ADMIN_PATH_SUFFIX },
...
});Step 2 — Access control per collection
Every collection has access functions that decide who can create, read, update, delete. Defaults often allow logged-in users more than intended.
// collections/Invoices.ts
import type { CollectionConfig } from 'payload/types';
const isAdmin = ({ req: { user } }) => user?.role === 'admin';
const isOwnerOrAdmin = ({ req: { user } }) => {
if (!user) return false;
if (user.role === 'admin') return true;
// Restrict to documents where ownerId equals the requesting user
return { ownerId: { equals: user.id } };
};
export const Invoices: CollectionConfig = {
slug: 'invoices',
access: {
create: ({ req: { user } }) => Boolean(user),
read: isOwnerOrAdmin,
update: isOwnerOrAdmin,
delete: isAdmin,
},
fields: [/* ... */],
};Patterns:
- Return
true/falsefor unconditional access. Easy to reason about. - Return a
Wherefilter for row-level restrictions. Payload applies it to every query — the requester only sees / mutates matching rows. - Always check
userfor null before reading properties. Unauthenticated requests passuser: undefined. - Default deny for
deleteon most collections. Public APIs almost never want delete.
Step 3 — Field-level access
Some fields are sensitive even within a collection a user can read.
fields: [
{ name: 'email', type: 'email' },
{
name: 'internalNotes',
type: 'textarea',
access: {
read: ({ req: { user } }) => user?.role === 'admin',
update: ({ req: { user } }) => user?.role === 'admin',
},
},
{
name: 'passwordResetToken',
type: 'text',
access: {
read: () => false, // never returned
update: () => false, // only set by hooks
},
},
],Field access is your protection against RSC over-fetch (see nextjs-security) and accidental API leakage.
Step 4 — Hook safety
beforeChange, afterRead, beforeRead hooks run with elevated context. They can leak or modify data in ways the access functions cannot prevent.
hooks: {
// Strip sensitive fields before returning to non-admins
afterRead: [({ doc, req: { user } }) => {
if (user?.role !== 'admin') {
delete doc.internalNotes;
delete doc.auditLog;
}
return doc;
}],
// Force ownerId server-side; never trust client to set it
beforeChange: [({ data, req: { user }, operation }) => {
if (operation === 'create' && user) {
data.ownerId = user.id;
}
return data;
}],
}Anti-patterns in hooks:
- Calling
payload.find()without passingreq— runs with full privileges, bypasses RLS-like access functions afterReadthat fetches related data without checking the caller's access to those related collectionsbeforeChangethat trusts client-provided IDs (use the session user instead)- Logging the whole document on every change — captures PII into logs
Step 5 — File uploads
If a collection has upload: true, every authenticated create writes a file to disk or S3. The combination of (a) user-supplied filename, (b) user-supplied MIME, and (c) the server later serving these files is the classic "upload PHP, hit URL" attack — even though Payload itself doesn't execute PHP, an attacker can target the storage layer.
{
slug: 'media',
upload: {
mimeTypes: ['image/jpeg', 'image/png', 'image/webp', 'application/pdf'],
staticDir: 'media',
imageSizes: [/* defined sizes — Payload re-encodes images */],
adminThumbnail: 'thumbnail',
},
access: {
create: ({ req: { user } }) => Boolean(user),
read: () => true, // typically public
delete: isOwnerOrAdmin,
},
}Hardening:
- Restrict
mimeTypesto a strict allowlist. Do not acceptapplication/octet-streamor*/*. - Server-side validate with
file-typeagainst the actual bytes — MIME from the client is advisory. - Strip EXIF / metadata from images (PII leak via geotags is common).
- Re-encode images through sharp / Payload's image processing — defangs polyglot files and clears most embedded payloads.
- Cap file size explicitly (
limits.fileSize). - Store with a server-generated name, never the user-supplied name on disk.
- Serve from a separate domain / Cloudflare Pages / R2 with proper
Content-Dispositionheaders and no script execution.
Step 6 — GraphQL / REST surface
Payload exposes both REST and GraphQL automatically. Anything reachable is in scope.
GraphQL introspection is on by default — turn off in production unless you have a reason to keep it:
graphQL: { disable: process.env.NODE_ENV === 'production' ? false : false } // Or restrict the playground: graphQL: { disablePlaygroundInProduction: true }depthLimitand complexity limits — protect against deeply nested query DoS. Payload +graphql-depth-limitorgraphql-query-complexity.Disable unused collections from the API entirely:
{ slug: 'audit-logs', admin: { hidden: true }, graphQL: false }- REST endpoints respect the same access functions, but custom endpoints (
endpoints: [...]) are not auto-protected — you write the auth check yourself.
Step 7 — Authentication settings
// payload.config.ts
const config = buildConfig({
cookiePrefix: 'app',
csrf: ['https://example.com'], // allowed origins for CSRF protection
cors: ['https://example.com'], // strict — not '*'
// For your auth-enabled collection (often Users)
// In the collection:
auth: {
tokenExpiration: 3600, // 1h — match your session policy
maxLoginAttempts: 5,
lockTime: 600 * 1000, // 10 min lockout
cookies: {
sameSite: 'Lax',
secure: true,
domain: 'example.com',
},
},
});See auth-hardening for the broader auth strategy.
Step 8 — Multi-tenant collections
If one Payload instance serves multiple tenants (common SaaS pattern), tenant isolation is a top concern. Three approaches, from simplest to strongest:
tenantIdfield + access filter — every document has a tenant, every access function filters by the requesting user's tenant. Single-DB simplicity, but a missed access function leaks across tenants.- Postgres RLS under Payload — the database enforces isolation even if the app makes a mistake. See
postgres-hardening. - Separate database per tenant — strongest isolation, highest operational cost.
For most apps, (1) with disciplined code review is fine. For regulated data, add (2).
Step 9 — Secrets and env
Payload reads from .env typically. Standard rules apply (see secret-hygiene).
Payload-specific notes:
PAYLOAD_SECRETis used for JWT signing. Rotating it invalidates all sessions — schedule rotations for low-traffic windows.- Database URL ends up in process env; ensure
process.envis not leaked via debug routes or error pages. - S3 credentials for media storage: scope the IAM policy to the specific bucket and operations needed (no
s3:*).
Step 10 — Update discipline
- Pin Payload to a patch version, watch the Payload security advisories feed
- Test upgrades on staging before prod
- Database migrations: Payload's auto-migration works for additive changes; manual review for column drops or type changes
Audit checklist
Before public launch:
- Admin path is behind SSO / Access / VPN, not on the open internet
- Every collection has explicit
accessfor create/read/update/delete - Sensitive fields have field-level access
- Hooks pass
reqtopayload.find/update/delete(so access functions apply) - File uploads have strict
mimeTypesand server-side validation - GraphQL introspection disabled in production (or you have a reason)
- CORS / CSRF lists are strict, no
* cookies.secure: true,sameSite: 'Lax'or stricterPAYLOAD_SECRETis at least 32 random chars, in env, not committed- No
payload.find()calls in user-reachable code paths withoutreq - Tenant isolation strategy documented and reviewed
What this skill will not do
- Help bypass Payload access controls on systems you do not own
- Endorse
access: { read: () => true }on collections holding personal data - Replace a Payload-specific security audit for high-stakes deployments
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

