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 stricter -
PAYLOAD_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