- name
- authentication
- description
- This skill should be used when the user asks to "add login to Payload", "set up OAuth/SSO", "write a custom auth strategy", "use API keys", "configure auth cookies", "customize verification emails", "refresh a JWT", or "handle forgot-password" — anything about Payload v3 authentication strategies, operations, or auth emails beyond the basics covered in collections.
# PayloadCMS — Authentication
Auth in Payload is a property of a collection, not a separate subsystem. Set `auth: true` (or an `AuthConfig` object) on any collection and it gains a login source, auto-generated `email`/`password`/`resetPasswordToken`/`loginAttempts`/`lockUntil` fields, a full set of auth REST endpoints, Local API methods, and `req.user` population on every request. The `collections` skill covers turning it on; this skill goes deeper — the four strategies, every operation, auth emails, token data, and locking down cookies.
## The Four Strategies
Payload ships four built-in ways to identify a user. They are not mutually exclusive — a collection can accept cookies, bearer JWTs, and API keys at once.
| Strategy | How the client authenticates | Enable with | Use for |
| --- | --- | --- | --- |
| HTTP-only Cookie | Browser sends the `payload-token` cookie automatically after login | On by default with `auth: true` | Admin panel, same-site frontends |
| JWT (bearer) | `Authorization: JWT <token>` header | On by default; token returned by login/refresh/me | Mobile apps, server-to-server, cross-origin |
| API Key | `Authorization: <slug> API-Key <key>` header | `useAPIKey: true` (add `{ reveal: true }` to let admins re-read keys, 3.90+) | Service accounts, third-party integrations |
| Custom | Your `authenticate` function resolves a user | `strategies: [...]` | OAuth/SSO, SAML, header-forwarded identity |
Cookies are immune to XSS (JavaScript cannot read them); JWTs are convenient for non-browser clients. Both carry the same signed payload.
## AuthConfig Reference
```ts
// src/collections/Users.ts
import type { CollectionConfig } from 'payload'
export const Users: CollectionConfig = {
slug: 'users',
admin: { useAsTitle: 'email' },
auth: {
tokenExpiration: 7200, // Seconds the session/JWT stays valid (2h)
verify: true, // Require email verification before login
maxLoginAttempts: 5, // Failed logins before lockout (0 disables)
lockTime: 600 * 1000, // Lockout duration in ms (10 min)
useAPIKey: false, // Per-user API keys; since 3.90 shown once at generation — `{ reveal: true }` lets admins re-read them
useSessions: true, // Default true; false => stateless JWT
depth: 0, // Relationship depth when building the JWT
removeTokenFromResponses: false, // Strip token from auth-op responses
cookies: { secure: true, sameSite: 'Lax' },
// disableLocalStrategy: true, // Advanced: turn off email/password entirely
// loginWithUsername: { ... }, // Username login (see below)
// strategies: [ ... ], // Custom strategies (see below)
// forgotPassword: { ... }, // Reset-email customization (see below)
// verify: { ... }, // Verify-email customization (see below)
},
fields: [
{
name: 'roles',
type: 'select',
hasMany: true,
saveToJWT: true, // Read roles from req.user without a DB hit
options: ['admin', 'editor', 'user'],
defaultValue: ['user'],
required: true,
},
],
}
```
Run `pnpm generate:types` after editing the collection so the generated `User` type stays in sync.
## Auth Operations
Every auth collection exposes these. REST paths are relative to `/api/<slug>`; Local API methods take `{ collection: '<slug>', ... }`.
| Operation | REST | Local API |
| --- | --- | --- |
| Login | `POST /<slug>/login` | `payload.login({ collection, data: { email, password } })` |
| Logout | `POST /<slug>/logout?allSessions=false` | `logout` server function (from `@payloadcms/next/auth`) |
| Me (current user) | `GET /<slug>/me` | read `req.user` |
| Refresh token | `POST /<slug>/refresh-token` | `refresh` server function |
| Forgot password | `POST /<slug>/forgot-password` | `payload.forgotPassword({ collection, data: { email }, disableEmail })` |
| Reset password | `POST /<slug>/reset-password` | `payload.resetPassword({ collection, data: { token, password }, overrideAccess })` |
| Verify email | `POST /<slug>/verify/<token>` | `payload.verifyEmail({ collection, token })` |
| Unlock | `POST /<slug>/unlock` | `payload.unlock({ collection, data: { email } })` |
Login (Local API):
```ts
// src/app/(auth)/actions.ts
const { user, token, exp } = await payload.login({
collection: 'users',
data: { email, password },
})
```
Login (REST) returns the user and sets the `payload-token` cookie; the JSON body also carries `token` for non-browser clients:
```ts
const res = await fetch('/api/users/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
})
const { user, token } = await res.json()
```
Kick off a reset and let Payload email the link, or suppress the email to handle delivery yourself:
```ts
const token = await payload.forgotPassword({
collection: 'users',
data: { email: 'jane@example.com' },
disableEmail: false, // true => you get the token back, no email sent
})
// Later, with the token from the email link:
await payload.resetPassword({
collection: 'users',
data: { token, password: 'new-secret' },
overrideAccess: true,
})
```
## API Keys Since 3.90
From 3.90.0 an API key is shown **once**, when it is generated. Payload then omits `apiKey` from every read (admin UI, REST, GraphQL, Local API) and exposes only the boolean `hasAPIKey`. Copy the key at generation time and keep it in a secret manager; to replace a lost key, regenerate it. Only users with admin access can generate, regenerate or revoke keys, and an API key is independent of the user's password — changing the password does not disable it.
To keep the pre-3.90 behaviour — admins can reveal stored keys from the Admin Panel — switch the boolean to an object:
```ts
// src/collections/ServiceAccounts.ts
auth: {
useAPIKey: {
reveal: true, // also registers POST /api/<slug>/:id/api-key/reveal (admin-only)
},
}
```
`reveal: true` also requires the revealing admin to have `read` and `update` access to the document and `update` access to the generated `apiKey` field. Leave it off unless a person genuinely needs to re-read a key — a key that cannot be read cannot leak through an over-broad `read` rule. Restrict who may generate keys by overriding the generated `apiKey` field's `create`/`update` access.
`useAPIKey: true` still compiles on 3.90; `{ reveal?: boolean }` is the object form.
## Upgrade to 3.90 (security release)
3.90.0 (2026-09-18) is a security release — upgrade promptly, then regenerate types and, on a relational database, create and run a migration:
```bash
pnpm payload generate:types
pnpm payload migrate:create upgrade-3-90 # SQL adapters only — adds resetPasswordRequestedAt and _objectKey columns
pnpm payload migrate
```
What changes for authentication:
- **Password change revokes other sessions** — no action needed; other devices must sign in again.
- **Password reset clears lockouts; forgot-password is throttled** — every auth collection gains a `resetPasswordRequestedAt` field, which is why the migration is required on Postgres/SQLite/D1. MongoDB needs no migration.
- **API keys are no longer readable** after generation — see above; opt back in with `useAPIKey: { reveal: true }`.
- **`schedulePublish` jobs now record the scheduling user's auth collection** — re-create any publish/unpublish events that were pending at upgrade time, and pass `user: { relationTo, value }` (value is the user ID) from custom queue code.
- **Form Builder, uploads, jobs** also changed defaults — see the `official-plugins`, `adapters` and `jobs-queue` skills.
## Auth Emails: Verify & Forgot-Password
Customize the verification and reset emails inside `auth`. Both objects accept `generateEmailHTML` and `generateEmailSubject`. `generateEmailHTML` receives `{ req, token, user }`; `generateEmailSubject` receives `{ req, user }`. Point the link at your own frontend route — not Payload's API.
```ts
// src/collections/Users.ts
auth: {
verify: {
generateEmailHTML: ({ token, user }) =>
`<p>Hi ${user.email}, confirm your account:</p>
<a href="${process.env.NEXT_PUBLIC_SITE_URL}/verify?token=${token}">Verify email</a>`,
generateEmailSubject: ({ user }) => `Verify your account, ${user.email}`,
},
forgotPassword: {
expiration: 3600 * 1000, // Reset-token validity in ms (1h)
generateEmailHTML: ({ token, user }) =>
`<p>Reset your password:</p>
<a href="${process.env.NEXT_PUBLIC_SITE_URL}/reset-password?token=${token}">Choose a new password</a>`,
generateEmailSubject: ({ user }) => `Hey ${user.email}, reset your password`,
},
}
```
These emails only send if an email transport is configured at the root `email:` key. SMTP, Resend, Nodemailer, etc. live in the `adapters` skill — without one, Payload logs the email to the console in dev and silently no-ops in prod.
## Token Data & `saveToJWT`
The JWT/cookie carries a small signed payload, read back as `req.user` on every request with no extra DB query. By default it holds `id`, `email`, and `collection`. Opt fields in per-field:
```ts
{ name: 'roles', type: 'select', hasMany: true, saveToJWT: true }
{ name: 'tenant', type: 'relationship', relationTo: 'tenants', saveToJWT: true }
{ name: 'internalNote', type: 'text', saveToJWT: false } // explicit exclude
{ name: 'plan', type: 'text', saveToJWT: 'subscriptionPlan' } // alternate JWT key
```
`group` and `tabs` fields store the whole object when `saveToJWT: true`, and child fields can override individually. Anything you need inside access functions or hooks (`req.user.roles`, `req.user.tenant`) must be marked `saveToJWT: true` — otherwise it's absent from `req.user` until you re-fetch the document.
Read it anywhere a `req` is in scope:
```ts
// In an access function, hook, or custom endpoint
if (req.user?.roles?.includes('admin')) return true
```
Tune `tokenExpiration` for session length, `maxLoginAttempts` + `lockTime` for brute-force protection (Payload increments `loginAttempts` and stamps `lockUntil` automatically), and `removeTokenFromResponses: true` when you only want cookie-based auth and never want the raw token in JSON.
## Cookies, CSRF & Cross-Domain
```ts
auth: {
cookies: {
secure: true, // HTTPS only — keep false in local dev over http
sameSite: 'Lax', // 'Strict' | 'Lax' | 'None'
domain: '.example.com', // Share the cookie across subdomains
},
}
```
Cookies are read from the responses of `login`, `logout`, `refresh`, and `me`. For a cross-origin frontend you must set `sameSite: 'None'` **and** `secure: true`, then whitelist origins for CSRF in the root config (`csrf`) and CORS (`cors`) — wildcards are not allowed once `sameSite: 'None'` is in play:
```ts
// src/payload.config.ts
export default buildConfig({
serverURL: process.env.PAYLOAD_URL,
cors: ['https://app.example.com'],
csrf: ['https://app.example.com'], // serverURL is added automatically
collections: [Users],
})
```
## `loginWithUsername` & `disableLocalStrategy`
Swap email-as-identifier for a username (with optional email fallback):
```ts
auth: {
loginWithUsername: {
allowEmailLogin: true, // Accept username OR email at the login form
requireEmail: false, // Make email optional at signup
},
}
```
Turn off email/password entirely when a collection authenticates only via API key or a custom strategy:
```ts
auth: {
useAPIKey: true, // Since 3.90 the key is shown once; use `{ reveal: true }` to let admins re-read it
disableLocalStrategy: true, // No password fields, no login op
}
```
## Custom Strategies (OAuth / SSO)
A custom strategy is the integration point for OAuth, SAML, or a reverse-proxy that forwards a trusted identity header. Add objects to `auth.strategies`. Each has a `name` and an `authenticate` function receiving `{ payload, headers, canSetHeaders, isGraphQL }`. It must resolve to `{ user }` where `user` is `{ collection: '<slug>', ...userDoc }` (or `null` to fall through to the next strategy), optionally with `responseHeaders`.
```ts
// src/auth/headerStrategy.ts
import type { AuthStrategy } from 'payload'
export const headerStrategy: AuthStrategy = {
name: 'forwarded-identity',
authenticate: async ({ payload, headers }) => {
const email = headers.get('x-forwarded-user') // set by a trusted proxy
if (!email) return { user: null }
const { docs } = await payload.find({
collection: 'users',
where: { email: { equals: email } },
limit: 1,
})
const found = docs[0]
return {
user: found ? { collection: 'users', ...found } : null,
}
},
}
```
Wire it in, usually alongside `disableLocalStrategy`:
```ts
// src/collections/Users.ts
auth: {
disableLocalStrategy: true,
strategies: [headerStrategy],
}
```
## Protecting Next.js Routes
In the App Router, resolve the user from headers via the Local API and gate server components or route handlers:
```ts
// src/app/(app)/dashboard/page.tsx
import { headers as nextHeaders } from 'next/headers'
import { getPayload } from 'payload'
import config from '@payload-config'
import { redirect } from 'next/navigation'
export default async function Dashboard() {
const payload = await getPayload({ config })
const { user } = await payload.auth({ headers: await nextHeaders() })
if (!user) redirect('/login')
return <h1>Welcome {user.email}</h1>
}
```
See the `nextjs-integration` skill for the full server-component, middleware, and route-handler patterns.
View on GitHub