323 lines
9.7 KiB
Markdown
323 lines
9.7 KiB
Markdown
---
|
|
tags: [payloadcms, tech-patterns]
|
|
topic: payloadcms
|
|
sources:
|
|
- https://payloadcms.com/docs/authentication/overview
|
|
- https://payloadcms.com/docs/authentication/operations
|
|
- https://payloadcms.com/docs/authentication/cookies
|
|
- https://payloadcms.com/docs/authentication/jwt
|
|
- https://payloadcms.com/docs/authentication/api-keys
|
|
- https://payloadcms.com/docs/authentication/email
|
|
- https://payloadcms.com/docs/authentication/custom-strategies
|
|
- https://payloadcms.com/docs/authentication/token-data
|
|
created: 2026-05-15
|
|
---
|
|
|
|
# PayloadCMS — Authentication
|
|
|
|
## Overview
|
|
|
|
Payload provides built-in, secure authentication on any Collection. When enabled, each document in the collection becomes a "user" with a full auth workflow: login/logout, password reset, email verification, token refresh, and account locking.
|
|
|
|
Three strategies ship out of the box and can work independently or together:
|
|
- **HTTP-only Cookies** — browser-managed, XSS-safe
|
|
- **JWT** — via `Authorization` header
|
|
- **API Keys** — non-expiring, per-user, encrypted at rest
|
|
|
|
Auth also integrates with [[wiki/payloadcms/access-control|Access Control]] — `req.user` is available in all access functions and hooks.
|
|
|
|
---
|
|
|
|
## Setup in Collection Config
|
|
|
|
Minimal — just `auth: true`:
|
|
|
|
```ts
|
|
import type { CollectionConfig } from 'payload'
|
|
|
|
export const Users: CollectionConfig = {
|
|
slug: 'users',
|
|
auth: true,
|
|
}
|
|
```
|
|
|
|
Full config with common options:
|
|
|
|
```ts
|
|
export const Customers: CollectionConfig = {
|
|
slug: 'customers',
|
|
auth: {
|
|
tokenExpiration: 7200, // seconds; applies to both JWT and cookie
|
|
verify: true, // require email verification before login
|
|
maxLoginAttempts: 5, // lock account after N failures
|
|
lockTime: 600 * 1000, // lockout duration in ms
|
|
useAPIKey: true, // enable per-user API keys
|
|
useSessions: true, // default true; false = stateless JWT
|
|
removeTokenFromResponses: false,// strip token from login/refresh responses
|
|
disableLocalStrategy: false, // set true to disable email+password auth
|
|
depth: 0, // populate depth for user doc in JWT
|
|
cookies: {
|
|
sameSite: 'None', // for cross-domain; requires secure: true
|
|
secure: true,
|
|
domain: 'example.com',
|
|
},
|
|
loginWithUsername: {
|
|
allowEmailLogin: true, // allow email OR username
|
|
requireEmail: false,
|
|
},
|
|
forgotPassword: {
|
|
expiration: 3600 * 1000, // reset token TTL in ms
|
|
},
|
|
},
|
|
}
|
|
```
|
|
|
|
**Auto-injected fields:** `email`, `hash`, `salt`. Do not define these manually unless overriding access control.
|
|
|
|
---
|
|
|
|
## Auth Operations
|
|
|
|
All operations are available via REST, Local API, and GraphQL.
|
|
|
|
| Operation | REST | Description |
|
|
|-----------|------|-------------|
|
|
| Login | `POST /api/{slug}/login` | Returns user + token; sets HTTP-only cookie |
|
|
| Logout | `POST /api/{slug}/logout` | Clears HTTP-only cookie; `?allSessions=true` ends all sessions |
|
|
| Me | `GET /api/{slug}/me` | Returns current user + token + expiry, or null |
|
|
| Refresh | `POST /api/{slug}/refresh-token` | Issues new token; requires non-expired current token |
|
|
| Forgot Password | `POST /api/{slug}/forgot-password` | Sends reset email |
|
|
| Reset Password | `POST /api/{slug}/reset-password` | Accepts `token` + `password` |
|
|
| Verify Email | `POST /api/{slug}/verify/:token` | Sets `_verified: true` |
|
|
| Unlock | `POST /api/{slug}/unlock` | Manually unlocks a locked account |
|
|
| Access | `GET /api/access` | Returns per-collection CRUD permissions for the logged-in user |
|
|
|
|
Local API examples:
|
|
|
|
```ts
|
|
// Login
|
|
const result = await payload.login({
|
|
collection: 'users',
|
|
data: { email: 'user@example.com', password: 'secret' },
|
|
})
|
|
|
|
// Forgot password (returns token, optionally skips email)
|
|
const token = await payload.forgotPassword({
|
|
collection: 'users',
|
|
data: { email: 'user@example.com' },
|
|
disableEmail: true,
|
|
})
|
|
|
|
// Verify email
|
|
await payload.verifyEmail({ collection: 'users', token: 'TOKEN' })
|
|
```
|
|
|
|
---
|
|
|
|
## Cookies vs JWT
|
|
|
|
### HTTP-Only Cookies
|
|
|
|
Set automatically on login. Browser includes them on same-origin requests with no extra code. Cannot be read by JavaScript — XSS-safe.
|
|
|
|
For `fetch` from JS code, add `credentials: 'include'`:
|
|
|
|
```ts
|
|
const res = await fetch('http://localhost:3000/api/pages', {
|
|
credentials: 'include',
|
|
})
|
|
```
|
|
|
|
**CSRF protection** — whitelist trusted origins in the root config:
|
|
|
|
```ts
|
|
buildConfig({
|
|
serverURL: 'https://api.example.com',
|
|
csrf: ['https://app.example.com'],
|
|
})
|
|
```
|
|
|
|
**Cross-domain setup** — cookies are third-party by default across domains. Workarounds:
|
|
1. Use subdomains (`app.example.com` → `api.example.com`) — no config needed.
|
|
2. Configure `sameSite: 'None'` + `secure: true` on the collection (see config above). Disable the wildcard in `cors` and list domains explicitly.
|
|
|
|
### JWT via Authorization Header
|
|
|
|
```ts
|
|
// Login to get token
|
|
const { token } = await fetch('/api/users/login', { method: 'POST', ... }).then(r => r.json())
|
|
|
|
// Use token on subsequent requests
|
|
const res = await fetch('/api/pages', {
|
|
headers: { Authorization: `JWT ${token}` },
|
|
})
|
|
```
|
|
|
|
**External JWT validation** — Payload hashes your secret before signing:
|
|
|
|
```ts
|
|
import crypto from 'node:crypto'
|
|
|
|
const secret = crypto
|
|
.createHash('sha256')
|
|
.update(process.env.PAYLOAD_SECRET)
|
|
.digest('hex')
|
|
.slice(0, 32)
|
|
// Use this `secret` in your external service's JWT.verify()
|
|
```
|
|
|
|
---
|
|
|
|
## API Keys
|
|
|
|
Per-user, non-expiring keys encrypted in the database. Useful for third-party service integrations.
|
|
|
|
```ts
|
|
export const Integrations: CollectionConfig = {
|
|
slug: 'integrations',
|
|
auth: {
|
|
useAPIKey: true,
|
|
disableLocalStrategy: true, // API-key-only collection
|
|
},
|
|
}
|
|
```
|
|
|
|
Usage in requests:
|
|
|
|
```ts
|
|
const res = await fetch('http://localhost:3000/api/pages', {
|
|
headers: {
|
|
Authorization: `integrations API-Key ${YOUR_API_KEY}`,
|
|
// format: "{collection-slug} API-Key {key}"
|
|
},
|
|
})
|
|
```
|
|
|
|
**Warning:** changing `PAYLOAD_SECRET` invalidates all existing API keys — they are encrypted with the secret.
|
|
|
|
---
|
|
|
|
## Custom Strategies
|
|
|
|
Remove Passport dependency (dropped in v3). A strategy is an object with `name` and `authenticate`.
|
|
|
|
```ts
|
|
export const Users: CollectionConfig = {
|
|
slug: 'users',
|
|
auth: {
|
|
disableLocalStrategy: true,
|
|
strategies: [
|
|
{
|
|
name: 'header-code-strategy',
|
|
authenticate: async ({ payload, headers, canSetHeaders }) => {
|
|
const result = await payload.find({
|
|
collection: 'users',
|
|
where: {
|
|
code: { equals: headers.get('x-code') },
|
|
secret: { equals: headers.get('x-secret') },
|
|
},
|
|
})
|
|
|
|
return {
|
|
user: result.docs[0]
|
|
? { collection: 'users', ...result.docs[0] }
|
|
: null,
|
|
responseHeaders: new Headers({ 'x-authenticated': 'true' }),
|
|
}
|
|
},
|
|
},
|
|
],
|
|
},
|
|
}
|
|
```
|
|
|
|
`authenticate` receives: `{ payload, headers, canSetHeaders, isGraphQL }`.
|
|
Returns: `{ user: UserDoc | null, responseHeaders?: Headers }`.
|
|
|
|
**Note:** strategy changes require a full server restart — not hot-reloaded.
|
|
|
|
---
|
|
|
|
## Token Data / Custom Claims
|
|
|
|
Control what ends up in the JWT / cookie by setting `saveToJWT` on fields:
|
|
|
|
```ts
|
|
export const Users: CollectionConfig = {
|
|
slug: 'users',
|
|
auth: true,
|
|
fields: [
|
|
{
|
|
name: 'role',
|
|
type: 'select',
|
|
options: ['super-admin', 'user'],
|
|
saveToJWT: true, // stored in token at field name
|
|
},
|
|
{
|
|
name: 'tenantId',
|
|
type: 'text',
|
|
saveToJWT: 'tenant_id', // stored under custom key
|
|
},
|
|
{
|
|
name: 'meta',
|
|
type: 'group',
|
|
saveToJWT: true, // entire group stored; use saveToJWT: false on children to exclude
|
|
fields: [
|
|
{ name: 'plan', type: 'text' },
|
|
{ name: 'internalNote', type: 'text', saveToJWT: false },
|
|
],
|
|
},
|
|
],
|
|
}
|
|
```
|
|
|
|
Access in [[wiki/payloadcms/hooks|Hooks]] and access control:
|
|
|
|
```ts
|
|
access: {
|
|
read: ({ req }) => {
|
|
if (!req.user) return false
|
|
return req.user.role === 'super-admin'
|
|
},
|
|
},
|
|
```
|
|
|
|
---
|
|
|
|
## Email Verification
|
|
|
|
```ts
|
|
auth: {
|
|
verify: {
|
|
generateEmailHTML: ({ req, token, user }) => {
|
|
const url = `https://app.example.com/verify?token=${token}`
|
|
return `<a href="${url}">Verify your account</a>`
|
|
},
|
|
generateEmailSubject: ({ user }) => `Verify your email, ${user.email}`,
|
|
},
|
|
}
|
|
```
|
|
|
|
After receiving the token on your frontend, call `POST /api/{slug}/verify/:token` yourself.
|
|
|
|
---
|
|
|
|
## Gotchas
|
|
|
|
- **`auth: true` auto-injects** `hash`, `salt`, `email` fields. Redefine them in `fields[]` only to override access control — set `hidden: true` on `password` to avoid Admin UI duplication.
|
|
- **`depth: 0`** on auth by default. Increasing it populates relationships in `req.user` but hits performance on every authenticated request.
|
|
- **`lockTime`** is in milliseconds; `tokenExpiration` is in seconds — easy to mix up.
|
|
- **Cross-domain cookies** need `sameSite: 'None'` + `secure: true`. `secure: true` breaks on `http://localhost` — use `process.env.NODE_ENV !== 'development'` guard.
|
|
- **Logout HTTP-only cookies** cannot be deleted from JS — always use the logout operation endpoint.
|
|
- **`allSessions: true`** in logout ends all sessions for the user, not just the current one.
|
|
- **External JWT validation**: Payload hashes `PAYLOAD_SECRET` via SHA-256, takes first 32 chars — your external verifier must replicate this.
|
|
- **Custom strategies** are NOT hot-reloaded — restart the dev server after changes.
|
|
- **Changing `PAYLOAD_SECRET`** invalidates all API keys (re-encrypt) and all existing JWTs.
|
|
- **Auto-login** (`admin.autoLogin`) should be gated behind `process.env.NODE_ENV === 'development'` — never ship to production.
|
|
|
|
---
|
|
|
|
## Related
|
|
|
|
- [[wiki/payloadcms/access-control|Access Control]]
|
|
- [[wiki/payloadcms/hooks|Hooks]]
|