--- 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 `Verify your account` }, 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]]