@purposeinplay/payload-totp
v0.4.1
Published
TOTP 2FA plugin for Payload CMS 3 — encrypted secrets, backup codes, middleware guard, and admin setup/verify views.
Readme
@purposeinplay/payload-totp
TOTP-based two-factor authentication plugin for Payload CMS 3 — encrypted secrets, backup codes, Next.js middleware guard, and admin setup/verify views.
Features
- Zero-config endpoints and admin views — add
totpPlugin()to your Payload config; collection REST routes and admin UI routes are registered for you - TOTP (RFC 6238: SHA1, 6 digits, 30s period, ±1 window, 20-byte secret) with QR code setup and manual entry
- Encrypted secrets at rest (AES-256-GCM)
- Bcrypt-hashed backup codes (10 codes, 64-bit entropy each)
- Works on every official database adapter — Postgres, MongoDB, SQLite, and Cloudflare D1, with numeric, ObjectID, or UUID user ids (each adapter is exercised in CI)
- Persistent rate limiting backed by a hidden
totp-rate-limitsPayload collection (survives restarts and scales across app instances on every adapter) - TOTP replay prevention (per-user counter tracking with atomic optimistic locking)
- HKDF-derived purpose-specific HMAC keys for cookies
- Edge-safe Next.js middleware helper with full cookie signature verification
- Blocking gate (
TOTPGate) to prevent dashboard flash before the TOTP redirect - Audit event integration (callback and/or @purposeinplay/payload-audit-log)
- shadcn-style InputOTP for accessible code entry in admin views
Requirements
Peer dependencies (installed in your app):
payload^3.0.0next^15.0.0 || ^16.0.0react/react-dom^18.0.0 || ^19.0.0@payloadcms/ui^3.0.0
Also:
- Node.js >= 20
- ESM-only package (
"type": "module") — no CommonJSrequire() PAYLOAD_SECRETenvironment variable (standard Payload requirement)TOTP_ENCRYPTION_KEYenvironment variable (see below)- Any official database adapter — see Database compatibility
Installation
pnpm add @purposeinplay/payload-totp
# or
npm install @purposeinplay/payload-totp
# or
yarn add @purposeinplay/payload-totpThe admin views are registered as import-map component strings (@purposeinplay/payload-totp/client#VerifyTOTPView), so regenerate the Payload import map after installing:
pnpm payload generate:importmapEnvironment Variables
TOTP_ENCRYPTION_KEY (required)
A 32-byte key for AES-256-GCM encryption of TOTP secrets at rest. Hex only — exactly 64 hex characters. The setup, verify, validate, and disable endpoints return 503 if it is missing or invalid (the status endpoint does not check it).
Generate one:
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"Add to .env:
TOTP_ENCRYPTION_KEY=<64 hex characters>This key is separate from PAYLOAD_SECRET so that Payload secret rotation does not break 2FA.
Warning: Never rotate this key without re-encrypting all stored secrets first.
PAYLOAD_SECRET (standard)
Used for HKDF key derivation (cookie HMACs) and JWT signing/verification. Already required by Payload. Use at least 32 characters — the plugin logs a console warning at startup if it is shorter.
NEXT_BASE_PATH (optional)
If your app is deployed under a Next.js basePath, set this so the redirectUrl returned by the status endpoint navigates correctly.
Quick Start
Consumers wire three touchpoints: Payload config, Next.js middleware, and the admin layout.
1. payload.config.ts
import { buildConfig } from 'payload';
import { totpPlugin } from '@purposeinplay/payload-totp';
export default buildConfig({
// ...
plugins: [
totpPlugin({
authCollectionSlug: 'users', // Auth collection to attach to (default 'users')
issuer: 'My App', // Name shown in authenticator apps
forceSetup: true, // Force users without TOTP to enable 2FA (all roles, admins included)
onAuditEvent: (event) => { // Optional audit callback
console.log(event.action, event.userId);
},
}),
],
});Endpoints, fields, and admin views are injected by the plugin; you do not add custom API routes or page files for TOTP.
2. middleware.ts
Next.js requires middleware in a root (or src) middleware.ts. Run the TOTP check before other logic:
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { handleTOTPMiddleware } from '@purposeinplay/payload-totp/middleware';
export async function middleware(request: NextRequest) {
const totpResponse = await handleTOTPMiddleware(request);
if (totpResponse) return totpResponse;
// Forward the pathname so <TOTPGuard> knows the current route
const headers = new Headers(request.headers);
headers.set('x-pathname', request.nextUrl.pathname);
return NextResponse.next({ request: { headers } });
}handleTOTPMiddleware(request, options?) accepts TOTPMiddlewareOptions:
| Option | Default | Description |
|--------|---------|-------------|
| adminRoute | '/admin' | Admin base path to protect |
| authCollectionSlug | 'users' | Used to derive the logout API path |
| pendingCookieMaxAge | — | Reserved; not currently consumed |
It redirects unverified sessions to {adminRoute}/verify-totp?back=<path> and clears the verified cookie on POST /api/{authCollectionSlug}/logout. It is edge-safe (verifies the payload-token JWT, the verified-cookie HMAC, the user ID, and the JWT iat binding without touching the database).
Note:
TOTPGuardreads the current path from anx-pathnamerequest header, which this plugin does not set for you. Without the header-forwarding shown above,TOTPGuardtreats every request as the admin root.
3. admin/layout.tsx
Wrap the Payload admin with the guard and gate so unverified sessions are redirected before the dashboard renders:
import { Suspense } from 'react';
import { TOTPGuard, TOTPGate } from '@purposeinplay/payload-totp/guard';
import configPromise from '@payload-config';
const AdminLayout = ({ children }: { children: React.ReactNode }) => (
<Suspense fallback={null}>
<TOTPGuard config={configPromise}>
<TOTPGate>{children}</TOTPGate>
</TOTPGuard>
</Suspense>
);
export default AdminLayout;TOTPGuard(server component) props:config(required, the Payload config promise),adminRoute(default'/admin'),forceSetup(defaulttrue— this prop is independent of the plugin'sforceSetupoption).TOTPGate(client component) props:loadingComponent?: React.ReactNode— rendered while it calls the status endpoint and blocks children until the session is clear.
What the Plugin Provides
Automatically (via totpPlugin()):
- Collection endpoints on the auth collection (URLs shown for the default
usersslug and/apiroute):POST /api/users/totp/setupPOST /api/users/totp/verifyPOST /api/users/totp/validateGET /api/users/totp/statusDELETE /api/users/totp/disable
- Admin views (registered in
admin.components.views):/admin/verify-totpand/admin/setup-totp totpfield group on the auth collection (enabled,secret,backupCodes,lastCounter; hidden in the admin UI,saveToJWTon the group andenabled). Skipped if atotpfield already exists.- Hidden
totp-rate-limitscollection (database table/collectionpayload_totp_rate_limits) holding persistent rate-limit state — managed by Payload, invisible in the admin UI, inaccessible through the API
Endpoint reference
All mutating endpoints require an authenticated user and are rate limited (429 with a Retry-After header on lockout). The mutating endpoints (setup, verify, validate, disable) return 503 if TOTP_ENCRYPTION_KEY is unset or invalid, and also 503 (with Retry-After) if the rate-limit store itself is unavailable — an infrastructure failure is never reported as "too many attempts". The status endpoint checks neither.
| Endpoint | Request | Response / notes |
|----------|---------|------------------|
| POST .../totp/setup | — | { qrCode, manualEntryKey } — QR data URL + base32 secret. 409 if TOTP already enabled. |
| POST .../totp/verify | { token } | Confirms setup. Returns { backupCodes } (plaintext, shown once), sets totp.enabled, rotates the payload-token cookie (preserving the Payload session) and sets the verified cookie. 400 if setup was never called, 409 if already enabled, 422 on invalid code, 500 if the anti-replay counter cannot be persisted. |
| POST .../totp/validate | { token } or { backupCode } | Post-login verification. Sets the verified cookie bound to the current JWT iat. 410 if TOTP is not enabled, 422 on invalid/replayed code. |
| GET .../totp/status?pathname=<path> | — | Returns { redirectUrl: string \| null } — a redirect-guard check used by TOTPGate (null when no redirect is needed or the user is unauthenticated). It does not return enabled/forced flags. |
| DELETE .../totp/disable | Self: { token }. Admin disabling another user: ?userId=<id> + { password }. Body must be application/json. | Clears TOTP fields (including the anti-replay counter); self-disable also clears the payload-token and verified cookies. 400 on a malformed userId or non-object JSON body, 415 on a non-JSON body, 403/404/422 on auth/validation failure. |
Exports
The published package ships five subpaths (see publishConfig.exports):
| Subpath | What to import |
|---------|----------------|
| @purposeinplay/payload-totp | totpPlugin, handleTOTPMiddleware, getTOTPRedirectPath, default constants (DEFAULT_BACKUP_CODE_COUNT, DEFAULT_FORCE_SETUP, DEFAULT_PENDING_COOKIE_MAX_AGE_SEC, DEFAULT_RATE_LIMIT_LOCKOUT_MS, DEFAULT_RATE_LIMIT_MAX_FAILURES, DEFAULT_RATE_LIMIT_WINDOW_MS, DEFAULT_VERIFIED_COOKIE_MAX_AGE_SEC), types (TOTPPluginOptions, TOTPAuditEvent, TOTPSecret, UserForGuard, UserForToken). Note: TOTPGuard/TOTPGate are not exported here — use ./guard. |
| @purposeinplay/payload-totp/guard | TOTPGuard (server component), TOTPGate (client component), getTOTPRedirectPath, type GetTOTPRedirectPathOptions |
| @purposeinplay/payload-totp/middleware | handleTOTPMiddleware (+ TOTPMiddlewareOptions), buildClearTOTPVerifiedCookieHeader, getTOTPVerifiedCookieName — edge-safe |
| @purposeinplay/payload-totp/client | TOTPGate, SetupTOTPView, VerifyTOTPView ('use client'; the views are used by Payload's admin import map — you normally do not import them in app code) |
| @purposeinplay/payload-totp/testing | createPendingCookieValueForTesting, verifyPendingCookie, createTOTPVerifiedCookieValue, getTOTPVerifiedCookieName, createPayloadToken — test helpers for forging valid cookies/tokens. Throws at import time if NODE_ENV === 'production'. |
Plugin Options
Options that are consumed at runtime:
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| enabled | boolean | true | Set false to disable the plugin entirely (no fields, endpoints, or views are registered) |
| authCollectionSlug | string | 'users' | Auth collection to inject TOTP fields and endpoints into |
| issuer | string | 'Casino CMS' | Issuer name shown in authenticator apps — you almost certainly want to set this |
| forceSetup | boolean | true | Whether the status endpoint redirects users without TOTP to setup. Applies to all roles, admins included. TOTPGuard has its own independent forceSetup prop. |
| onAuditEvent | (event: TOTPAuditEvent) => void \| Promise<void> | — | Audit event callback |
| rateLimit | { maxFailures?, lockoutMs?, windowMs? } | 5 / 900000 / 60000 | Failed attempts before lockout, lockout duration, sliding window |
| cookieMaxAge | number | 28800 (8h) | Verified cookie lifetime in seconds |
| tokenExpiration | string | '7d' | Expiry of the payload-token re-issued after setup verification |
Not yet wired: the following options exist on the
TOTPPluginOptionstype but are currently ignored at runtime — the hardcoded defaults always apply:backupCodeCount(10),pendingCookieMaxAge(300s),encryptionKey(only theTOTP_ENCRYPTION_KEYenv var is read), andloadingComponent(use theloadingComponentprop on<TOTPGate>instead).
Database compatibility
The plugin works identically on every official adapter. Both database-critical operations — TOTP replay prevention (compare-and-set on the per-user counter) and rate limiting — run through an adapter-agnostic atomic primitive:
| Adapter | Atomic path | User id shapes tested |
|---------|-------------|-----------------------|
| @payloadcms/db-postgres (incl. idType: 'uuid') | Single UPDATE … WHERE … RETURNING via the adapter's own drizzle instance | serial number, uuid string |
| @payloadcms/db-sqlite | Same drizzle path (dialect-correct SQL emitted by drizzle) | serial number |
| @payloadcms/db-d1-sqlite (Cloudflare D1) | Same drizzle path | serial number |
| @payloadcms/db-mongodb | Native atomic updateOne(filter, { $set }) on the mongoose model | ObjectID string |
| Unknown/third-party adapters | Portable fallback via payload.db.updateOne({ where }) — best-effort atomicity, a one-time warning is logged | — |
All time arithmetic happens in application code (no NOW() / SQL date functions), so there is no dialect drift. Every adapter above is exercised in CI with the full lifecycle suite, including a concurrent-validation race test that asserts exactly one of two parallel validations of the same code succeeds.
Failure semantics
| Condition | Behaviour |
|-----------|-----------|
| Rate-limit DB operation fails (all endpoints, default failClosed: true) | Request denied with 503 and a 30s Retry-After — reported honestly as an infrastructure failure, never as a lockout; logged at error level |
| Unknown adapter | Fallback path is used; payload.logger warns once per process (the adapter's updateOne must honour returning: true) |
| Replay-counter update fails | The validation request errors (fail closed) — a DB outage can never mint a verified session |
Upgrading from 0.1.x
- Postgres installs: pre-0.2.0 versions auto-created a raw
totp_rate_limitstable. The new Payload-managed collection uses a different table name (payload_totp_rate_limits), so runpayload migrate:create && payload migrate(or rely on dev push mode), then optionallyDROP TABLE IF EXISTS totp_rate_limits;— the data is ephemeral lockout state. - All installs: the verified/pending cookies use a new signed format; every user is asked to re-enter their TOTP code once after deploying.
- MongoDB / SQLite / D1 installs: TOTP validation and persistent rate limiting now simply work — no action needed.
Security Considerations
- Key rotation:
TOTP_ENCRYPTION_KEYmust never be rotated without re-encrypting all secrets first. - Rate limiting: All TOTP endpoints (including setup) fail closed if the rate-limit database operation errors — the request is denied with
503and a 30s retry hint (never mislabelled as a lockout). Defaults: 5 failures per 1-minute window, 15-minute lockout, cleared on success. - Backup codes: bcrypt with cost factor 10, 64-bit entropy (16 uppercase-hex chars each). Shown in plaintext exactly once, on
verify. Consumption is atomic per code (a conditional single-statement flip of theusedflag), so a code stays single-use even under concurrent validation attempts. - Cookie security: the
payload-totp-verifiedcookie is HttpOnly, SameSite=Strict, Secure in production, HMAC-signed with an HKDF-derived purpose key, and bound to the JWTiatclaim so it cannot be replayed across sessions. - Replay prevention: the last successful TOTP counter is tracked per user with an atomic conditional update (optimistic locking) to prevent concurrent replay.
- TLS requirement: the setup endpoint returns the plaintext secret (required for authenticator app registration). Enforce TLS in production.
- Admin disable uses
payload.login()for password verification: this has side effects (JWT generation, login hooks, session creation). Not exploitable given the admin+2FA gate, but would ideally be a direct password comparison if Payload exposes one publicly. - JWT expiry: the token rotated on
verifyhonours thetokenExpirationoption (default 7 days) and preserves the original session id (sid) so Payload's session-based auth keeps working.
Cryptographic design
PAYLOAD_SECRET
├── HKDF-SHA256("payload-totp:verified-cookie-hmac") → HMAC key for verified cookie
├── HKDF-SHA256("payload-totp:pending-cookie-hmac") → HMAC key for pending cookie
└── SHA256(raw).hex.slice(0,32) → HS256 JWT signing (matches Payload internal)
TOTP_ENCRYPTION_KEY (separate env var, 64 hex chars)
└── AES-256-GCM for TOTP secret encryption at rest
bcrypt (no key, self-salting)
└── Backup code hashingAudit Events
Emitted to the onAuditEvent callback and, if a collection with slug audit-logs exists (e.g. from @purposeinplay/payload-audit-log), written there as security entries. Audit failures never block the request.
totp.setup.initiated— user started 2FA setuptotp.setup.completed— user completed 2FA setuptotp.validate.success— successful TOTP validationtotp.validate.failed— failed TOTP validation attempttotp.validate.backup— successful backup code validationtotp.disabled.self— user disabled their own 2FAtotp.disabled.by-admin— admin disabled another user's 2FAtotp.rate-limit.lockout— rate limit lockout triggered
Upgrading from In-App 2FA
If migrating from an existing in-app TOTP implementation with plaintext secrets, the repository contains a migration script that encrypts existing secrets, clears legacy backup codes, and writes a JSON backup of the originals.
The script lives at
src/scripts/migrate-to-encrypted.tsin the monorepo source — it is not shipped in the npm package (onlydist/is published). Run it from a checkout of purposeinplay/payload-plugins. It requiresTOTP_ENCRYPTION_KEY,DATABASE_URI, andPAYLOAD_SECRET, and loads your config from<cwd>/src/payload.config.ts.
TOTP_ENCRYPTION_KEY=<key> DATABASE_URI=<uri> PAYLOAD_SECRET=<secret> \
pnpm tsx packages/payload-totp/src/scripts/migrate-to-encrypted.tsThen generate and apply the schema migration (pnpm payload migrate:create / pnpm payload migrate). Users with cleared backup codes receive new ones on their next TOTP setup.
Testing (contributors)
Unit tests (pnpm test) run without a database. The integration matrix boots a real Payload instance per adapter and drives the full TOTP lifecycle — including concurrent-validation and backup-code race tests:
pnpm test:int:sqlite # no setup needed (also the default for pnpm test:int)
pnpm test:int:mongodb # mongodb-memory-server, no setup needed
pnpm test:int:d1 # real Miniflare D1 binding via wrangler, no setup needed
docker compose -f tests/int/docker-compose.yml up -d # local Postgres on :5434
POSTGRES_URL=postgres://postgres:postgres@localhost:5434/totp_test pnpm test:int:postgres
POSTGRES_URL=postgres://postgres:postgres@localhost:5434/totp_test pnpm test:int:postgres-uuidCI runs all five configurations on every PR. Before a release that touches the database layer, also run the manual real-D1 smoke checklist in dev/README.md (Miniflare's local D1 is not production D1).
License
Part of the purposeinplay/payload-plugins monorepo — see the root repository for contributing guidelines and changelogs (managed with Changesets). Issues: github.com/purposeinplay/payload-plugins/issues.
