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

9.7 KiB

tags topic sources created
payloadcms
tech-patterns
payloadcms
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
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-controlreq.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:

  1. Use subdomains (app.example.comapi.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

// 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: 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.