123 lines
3.0 KiB
Plaintext
123 lines
3.0 KiB
Plaintext
---
|
|
title: Critical Security Patterns
|
|
description: The three most important security patterns in Payload CMS
|
|
tags: [payload, security, critical, access-control, transactions, hooks]
|
|
priority: high
|
|
---
|
|
|
|
# CRITICAL SECURITY PATTERNS
|
|
|
|
These are the three most critical security patterns that MUST be followed in every Payload CMS project.
|
|
|
|
## 1. Local API Access Control (MOST IMPORTANT)
|
|
|
|
**By default, Local API operations bypass ALL access control**, even when passing a user.
|
|
|
|
```typescript
|
|
// ❌ SECURITY BUG: Passes user but ignores their permissions
|
|
await payload.find({
|
|
collection: 'posts',
|
|
user: someUser, // Access control is BYPASSED!
|
|
})
|
|
|
|
// ✅ SECURE: Actually enforces the user's permissions
|
|
await payload.find({
|
|
collection: 'posts',
|
|
user: someUser,
|
|
overrideAccess: false, // REQUIRED for access control
|
|
})
|
|
|
|
// ✅ Administrative operation (intentional bypass)
|
|
await payload.find({
|
|
collection: 'posts',
|
|
// No user, overrideAccess defaults to true
|
|
})
|
|
```
|
|
|
|
**When to use each:**
|
|
|
|
- `overrideAccess: true` (default) - Server-side operations you trust (cron jobs, system tasks)
|
|
- `overrideAccess: false` - When operating on behalf of a user (API routes, webhooks)
|
|
|
|
**Rule**: When passing `user` to Local API, ALWAYS set `overrideAccess: false`
|
|
|
|
## 2. Transaction Safety in Hooks
|
|
|
|
**Nested operations in hooks without `req` break transaction atomicity.**
|
|
|
|
```typescript
|
|
// ❌ DATA CORRUPTION RISK: Separate transaction
|
|
hooks: {
|
|
afterChange: [
|
|
async ({ doc, req }) => {
|
|
await req.payload.create({
|
|
collection: 'audit-log',
|
|
data: { docId: doc.id },
|
|
// Missing req - runs in separate transaction!
|
|
})
|
|
},
|
|
]
|
|
}
|
|
|
|
// ✅ ATOMIC: Same transaction
|
|
hooks: {
|
|
afterChange: [
|
|
async ({ doc, req }) => {
|
|
await req.payload.create({
|
|
collection: 'audit-log',
|
|
data: { docId: doc.id },
|
|
req, // Maintains atomicity
|
|
})
|
|
},
|
|
]
|
|
}
|
|
```
|
|
|
|
**Why This Matters:**
|
|
|
|
- **MongoDB (with replica sets)**: Creates atomic session across operations
|
|
- **PostgreSQL**: All operations use same Drizzle transaction
|
|
- **SQLite (with transactions enabled)**: Ensures rollback on errors
|
|
- **Without req**: Each operation runs independently, breaking atomicity
|
|
|
|
**Rule**: ALWAYS pass `req` to nested operations in hooks
|
|
|
|
## 3. Prevent Infinite Hook Loops
|
|
|
|
**Hooks triggering operations that trigger the same hooks create infinite loops.**
|
|
|
|
```typescript
|
|
// ❌ INFINITE LOOP
|
|
hooks: {
|
|
afterChange: [
|
|
async ({ doc, req }) => {
|
|
await req.payload.update({
|
|
collection: 'posts',
|
|
id: doc.id,
|
|
data: { views: doc.views + 1 },
|
|
req,
|
|
}) // Triggers afterChange again!
|
|
},
|
|
]
|
|
}
|
|
|
|
// ✅ SAFE: Use context flag
|
|
hooks: {
|
|
afterChange: [
|
|
async ({ doc, req, context }) => {
|
|
if (context.skipHooks) return
|
|
|
|
await req.payload.update({
|
|
collection: 'posts',
|
|
id: doc.id,
|
|
data: { views: doc.views + 1 },
|
|
context: { skipHooks: true },
|
|
req,
|
|
})
|
|
},
|
|
]
|
|
}
|
|
```
|
|
|
|
**Rule**: Use `req.context` flags to prevent hook loops
|