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

@arthur.eudeline/payload-plugin-mfa

v0.1.2

Published

Two-factor authentication (TOTP + one-time backup codes) for the Payload CMS admin panel.

Readme

@arthur-eudeline/payload-plugin-mfa

Two-factor authentication for the Payload CMS 3 admin panel: TOTP (Google Authenticator, 1Password, Aegis…) plus one-time backup codes. Payload 3 ships nothing for this — the plugin adds the fields, the endpoints, the login step and the enrolment UI.

pnpm add @arthur-eudeline/payload-plugin-mfa
// payload.config.ts
import { mfaPlugin } from '@arthur-eudeline/payload-plugin-mfa';

export default buildConfig({
  admin: { user: 'users' },
  collections: [Users /* … */],
  plugins: [mfaPlugin({ issuer: 'Acme' })],
});

Then, once:

pnpm payload generate:types
pnpm payload generate:importmap
pnpm payload migrate:create   # the plugin adds columns

That is the whole setup. Users enable 2FA themselves from /admin/account; the next sign-in asks for a code.

What it does

  • Enrolment — a panel on the user's own account renders a QR code, confirms the secret with a first valid code, then shows ten single-use backup codes once.
  • Sign-in/admin/login is replaced by a two-step form: email + password first, then the code, only for accounts that have 2FA on. Backup codes are accepted in the same field.
  • RecoveryresetMfa() for the one dead end the UI can't fix (phone and backup codes lost).

Security properties

  • The TOTP secret is encrypted at rest with payload.encrypt (keyed on PAYLOAD_SECRET); backup codes are stored as per-code salted scrypt hashes, never in clear.
  • Every MFA field denies create/update through the API and the secrets also deny read. The only write path is the plugin's own endpoints, which use overrideAccess. A PATCH on /api/users/:id from a stolen session can neither disable 2FA nor plant a chosen secret.
  • The last accepted TOTP timestep is recorded, so an intercepted code cannot be replayed within its ±30 s validity window.
  • Five consecutive invalid codes lock the second factor for 15 minutes (both configurable). Payload's own maxLoginAttempts does not cover this: a bad code rolls the login transaction back, taking the attempt counter with it — which is why the counter is written from an afterError hook.
  • Every endpoint acts on req.user only. No account can enrol, unlock or disable another.
  • Disabling 2FA requires a valid TOTP code rather than the password: verifying a password outside the login flow would mean reimplementing Payload's internal hashing (authenticateLocalStrategy is not exported), and payload.login() would mint a stray session. Re-asking for the second factor is the guarantee that matters here — it blocks a stolen session, which is what 2FA is for.

Options

| Option | Default | | | ------------------- | --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | | collections | [config.admin.user] | Auth collections to protect. | | issuer | admin.meta.titleSuffix ?? 'Payload' | Name shown in the authenticator app. | | enabled | true | false neutralises the behaviour but keeps the fields, so an already-migrated database doesn't lose its columns. | | fieldName | 'mfa' | Name of the field group added to the collection. | | backupCodeCount | 10 | Backup codes issued on activation. | | maxAttempts | 5 | Consecutive invalid codes before locking. | | lockDuration | 900000 | Lock duration, in milliseconds. | | overrideLoginView | true | Set false to keep your own login view — then mount MfaLoginForm yourself, or handle the MFA_REQUIRED error code. | | labels | — | Per-language label overrides: { fr: { panelTitle: '…' } }. | | language | admin language | Pins the label language instead of following the admin's. |

English and French are built in; any other admin language falls back to English.

HTTP surface

All five are mounted on the protected collection and act on the caller only.

| | | | ----------------------------------------- | ---------------------------------------------------------------------------------------------------------- | | GET /api/<collection>/mfa/status | { enabled, confirmedAt, backupCodesRemaining, hasPendingSecret } | | POST /api/<collection>/mfa/setup | → { secret, qrCode } (data URL). Stores a pending secret; sign-in is unaffected until it is confirmed. | | POST /api/<collection>/mfa/activate | { code }{ backupCodes }. The only time backup codes are readable. | | POST /api/<collection>/mfa/backup-codes | { code }{ backupCodes }. Invalidates the previous set. | | POST /api/<collection>/mfa/disable | { code }{ enabled: false } |

On sign-in, POST /api/<collection>/login accepts an extra mfaCode field. Without it, an enrolled account gets 401 with errors[0].data.code === 'MFA_REQUIRED'; a wrong code gives MFA_INVALID, and a locked account MFA_LOCKED (429). Those constants are exported as MFA_ERROR_CODES.

Note for plugin authors. The error codes above are delivered through formatErrors's Array.isArray(message) branch rather than an APIError's data field. Payload only forwards data for an error it recognises with instanceof APIError, and that check fails in a Next.js production build — withPayload externalises payload only in development, so the bundled copy the plugin throws from is not the one formatErrors compares against. Going through the array branch is identity-independent and behaves the same in dev and in production.

Recovery

import { resetMfa } from '@arthur-eudeline/payload-plugin-mfa';

await resetMfa({ payload, email: '[email protected]' });

Server-side and privileged — call it from an admin script, never from an exposed route.

Exports

| | | | -------------------------------------------- | ------------------------------------------------------------------ | | @arthur-eudeline/payload-plugin-mfa | mfaPlugin, resetMfa, MFA_ERROR_CODES, builtInLabels, types | | @arthur-eudeline/payload-plugin-mfa/client | MfaPanel, MfaLoginForm, resolveLabels | | @arthur-eudeline/payload-plugin-mfa/rsc | MfaLoginView |

Requirements

Payload 3.88+, React 19, Next 15 or 16. The database adapter must support transactions (the Postgres, SQLite and MongoDB adapters all do).

License

MIT