obsidian/wiki/payloadcms/authentication.md
2026-05-15 15:13:56 +01:00

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]]