9.7 KiB
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
Authorizationheader - API Keys — non-expiring, per-user, encrypted at rest
Auth also integrates with wiki/payloadcms/access-control — req.user is available in all access functions and hooks.
Setup in Collection Config
Minimal — just auth: true:
import type { CollectionConfig } from 'payload'
export const Users: CollectionConfig = {
slug: 'users',
auth: true,
}
Full config with common options:
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:
// 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':
const res = await fetch('http://localhost:3000/api/pages', {
credentials: 'include',
})
CSRF protection — whitelist trusted origins in the root config:
buildConfig({
serverURL: 'https://api.example.com',
csrf: ['https://app.example.com'],
})
Cross-domain setup — cookies are third-party by default across domains. Workarounds:
- Use subdomains (
app.example.com→api.example.com) — no config needed. - Configure
sameSite: 'None'+secure: trueon the collection (see config above). Disable the wildcard incorsand list domains explicitly.
JWT via Authorization Header
// 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:
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.
export const Integrations: CollectionConfig = {
slug: 'integrations',
auth: {
useAPIKey: true,
disableLocalStrategy: true, // API-key-only collection
},
}
Usage in requests:
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.
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:
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 and access control:
access: {
read: ({ req }) => {
if (!req.user) return false
return req.user.role === 'super-admin'
},
},
Email Verification
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: trueauto-injectshash,salt,emailfields. Redefine them infields[]only to override access control — sethidden: trueonpasswordto avoid Admin UI duplication.depth: 0on auth by default. Increasing it populates relationships inreq.userbut hits performance on every authenticated request.lockTimeis in milliseconds;tokenExpirationis in seconds — easy to mix up.- Cross-domain cookies need
sameSite: 'None'+secure: true.secure: truebreaks onhttp://localhost— useprocess.env.NODE_ENV !== 'development'guard. - Logout HTTP-only cookies cannot be deleted from JS — always use the logout operation endpoint.
allSessions: truein logout ends all sessions for the user, not just the current one.- External JWT validation: Payload hashes
PAYLOAD_SECRETvia 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_SECRETinvalidates all API keys (re-encrypt) and all existing JWTs. - Auto-login (
admin.autoLogin) should be gated behindprocess.env.NODE_ENV === 'development'— never ship to production.