| name | payload-cms-security |
| description | 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. |
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
/admin behind SSO with Cloudflare Access, Tailscale, or a VPN. The admin should not be reachable from the open internet for most projects. See cloudflare-hardening.
- Rate limit auth endpoints —
/api/users/login and friends. Cloudflare Rate Limit or express-rate-limit on 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:
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.
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;
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/false for unconditional access. Easy to reason about.
- Return a
Where filter for row-level restrictions. Payload applies it to every query — the requester only sees / mutates matching rows.
- Always check
user for null before reading properties. Unauthenticated requests pass user: undefined.
- Default deny for
delete on 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,
update: () => false,
},
},
],
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: {
afterRead: [({ doc, req: { user } }) => {
if (user?.role !== 'admin') {
delete doc.internalNotes;
delete doc.auditLog;
}
return doc;
}],
beforeChange: [({ data, req: { user }, operation }) => {
if (operation === 'create' && user) {
data.ownerId = user.id;
}
return data;
}],
}
Anti-patterns in hooks:
- Calling
payload.find() without passing req — runs with full privileges, bypasses RLS-like access functions
afterRead that fetches related data without checking the caller's access to those related collections
beforeChange that 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: [],
adminThumbnail: 'thumbnail',
},
access: {
create: ({ req: { user } }) => Boolean(user),
read: () => true,
delete: isOwnerOrAdmin,
},
}
Hardening:
- Restrict
mimeTypes to a strict allowlist. Do not accept application/octet-stream or */*.
- Server-side validate with
file-type against 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-Disposition headers 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 }
graphQL: { disablePlaygroundInProduction: true }
-
depthLimit and complexity limits — protect against deeply nested query DoS. Payload + graphql-depth-limit or graphql-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
const config = buildConfig({
cookiePrefix: 'app',
csrf: ['https://example.com'],
cors: ['https://example.com'],
auth: {
tokenExpiration: 3600,
maxLoginAttempts: 5,
lockTime: 600 * 1000,
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:
tenantId field + 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_SECRET is used for JWT signing. Rotating it invalidates all sessions — schedule rotations for low-traffic windows.
- Database URL ends up in process env; ensure
process.env is 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:
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