payload-cms-security

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, admi

SKILL.md

GitHub

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:

  1. 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.
  2. Rate limit auth endpoints — /api/users/login and friends. Cloudflare Rate Limit or express-rate-limit on the Express app.
  3. 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/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,            // 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 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: [/* defined sizes — Payload re-encodes images */],
    adminThumbnail: 'thumbnail',
  },
  access: {
    create: ({ req: { user } }) => Boolean(user),
    read: () => true,             // typically public
    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 }
    // Or restrict the playground:
    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

// 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:

  1. 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.
  2. Postgres RLS under Payload — the database enforces isolation even if the app makes a mistake. See postgres-hardening.
  3. 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:

  • Admin path is behind SSO / Access / VPN, not on the open internet
  • Every collection has explicit access for create/read/update/delete
  • Sensitive fields have field-level access
  • Hooks pass req to payload.find/update/delete (so access functions apply)
  • File uploads have strict mimeTypes and 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_SECRET is at least 32 random chars, in env, not committed
  • No payload.find() calls in user-reachable code paths without req
  • 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