payload-cms-security — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited payload-cms-security (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
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.
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:
/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./api/users/login and friends. Cloudflare Rate Limit or express-rate-limit on the Express app.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 },
...
});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:
user: undefined.delete on most collections. Public APIs almost never want delete.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.
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:
payload.find() without passing req — runs with full privileges, bypasses RLS-like access functionsafterRead that fetches related data without checking the caller's access to those related collectionsbeforeChange that trusts client-provided IDs (use the session user instead)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:
application/octet-stream or */*.file-type against the actual bytes — MIME from the client is advisory.limits.fileSize).Content-Disposition headers and no script execution.Payload exposes both REST and GraphQL automatically. Anything reachable is in scope.
graphQL: { disable: process.env.NODE_ENV === 'production' ? false : false }
// Or restrict the playground:
graphQL: { disablePlaygroundInProduction: true }graphql-depth-limit or graphql-query-complexity. { slug: 'audit-logs', admin: { hidden: true }, graphQL: false }endpoints: [...]) are not auto-protected — you write the auth check yourself.// 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.
If one Payload instance serves multiple tenants (common SaaS pattern), tenant isolation is a top concern. Three approaches, from simplest to strongest:
postgres-hardening.For most apps, (1) with disciplined code review is fine. For regulated data, add (2).
Payload reads from .env typically. Standard rules apply (see secret-hygiene).
Payload-specific notes:
process.env is not leaked via debug routes or error pages.s3:*).Before public launch:
access for create/read/update/deletereq to payload.find/update/delete (so access functions apply)mimeTypes and server-side validation*cookies.secure: true, sameSite: 'Lax' or stricterPAYLOAD_SECRET is at least 32 random chars, in env, not committedpayload.find() calls in user-reachable code paths without reqaccess: { read: () => true } on collections holding personal data~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.