npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

npm version

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-limits Payload 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.0
  • next ^15.0.0 || ^16.0.0
  • react / react-dom ^18.0.0 || ^19.0.0
  • @payloadcms/ui ^3.0.0

Also:

  • Node.js >= 20
  • ESM-only package ("type": "module") — no CommonJS require()
  • PAYLOAD_SECRET environment variable (standard Payload requirement)
  • TOTP_ENCRYPTION_KEY environment 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-totp

The 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:importmap

Environment 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: TOTPGuard reads the current path from an x-pathname request header, which this plugin does not set for you. Without the header-forwarding shown above, TOTPGuard treats 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 (default true — this prop is independent of the plugin's forceSetup option).
  • 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 users slug and /api route):
    • POST /api/users/totp/setup
    • POST /api/users/totp/verify
    • POST /api/users/totp/validate
    • GET /api/users/totp/status
    • DELETE /api/users/totp/disable
  • Admin views (registered in admin.components.views): /admin/verify-totp and /admin/setup-totp
  • totp field group on the auth collection (enabled, secret, backupCodes, lastCounter; hidden in the admin UI, saveToJWT on the group and enabled). Skipped if a totp field already exists.
  • Hidden totp-rate-limits collection (database table/collection payload_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 TOTPPluginOptions type but are currently ignored at runtime — the hardcoded defaults always apply: backupCodeCount (10), pendingCookieMaxAge (300s), encryptionKey (only the TOTP_ENCRYPTION_KEY env var is read), and loadingComponent (use the loadingComponent prop 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_limits table. The new Payload-managed collection uses a different table name (payload_totp_rate_limits), so run payload migrate:create && payload migrate (or rely on dev push mode), then optionally DROP 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_KEY must 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 503 and 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 the used flag), so a code stays single-use even under concurrent validation attempts.
  • Cookie security: the payload-totp-verified cookie is HttpOnly, SameSite=Strict, Secure in production, HMAC-signed with an HKDF-derived purpose key, and bound to the JWT iat claim 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 verify honours the tokenExpiration option (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 hashing

Audit 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 setup
  • totp.setup.completed — user completed 2FA setup
  • totp.validate.success — successful TOTP validation
  • totp.validate.failed — failed TOTP validation attempt
  • totp.validate.backup — successful backup code validation
  • totp.disabled.self — user disabled their own 2FA
  • totp.disabled.by-admin — admin disabled another user's 2FA
  • totp.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.ts in the monorepo source — it is not shipped in the npm package (only dist/ is published). Run it from a checkout of purposeinplay/payload-plugins. It requires TOTP_ENCRYPTION_KEY, DATABASE_URI, and PAYLOAD_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.ts

Then 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-uuid

CI 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

MIT

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.