@arthur.eudeline/payload-plugin-mfa
v0.1.2
Published
Two-factor authentication (TOTP + one-time backup codes) for the Payload CMS admin panel.
Maintainers
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 columnsThat 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/loginis 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. - Recovery —
resetMfa()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 onPAYLOAD_SECRET); backup codes are stored as per-code salted scrypt hashes, never in clear. - Every MFA field denies
create/updatethrough the API and the secrets also denyread. The only write path is the plugin's own endpoints, which useoverrideAccess. APATCHon/api/users/:idfrom 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
maxLoginAttemptsdoes 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 anafterErrorhook. - Every endpoint acts on
req.useronly. 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 (
authenticateLocalStrategyis not exported), andpayload.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'sArray.isArray(message)branch rather than anAPIError'sdatafield. Payload only forwardsdatafor an error it recognises withinstanceof APIError, and that check fails in a Next.js production build —withPayloadexternalisespayloadonly in development, so the bundled copy the plugin throws from is not the oneformatErrorscompares 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
