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