{"_id":"@arthur.eudeline/payload-plugin-mfa","_rev":"2-662da5d66a41b4ba819713718dd2a717","name":"@arthur.eudeline/payload-plugin-mfa","dist-tags":{"latest":"0.1.2"},"versions":{"0.1.1":{"name":"@arthur.eudeline/payload-plugin-mfa","version":"0.1.1","keywords":["payload","payload-plugin","payloadcms","2fa","mfa","totp","authenticator"],"author":"Arthur Eudeline","license":"MIT","_id":"@arthur.eudeline/payload-plugin-mfa@0.1.1","maintainers":[{"name":"arthur.eudeline","email":"contact@arthur-eudeline.com"}],"homepage":"https://github.com/arthur-eudeline/payload-plugins/tree/main/packages/payload-plugin-mfa#readme","bugs":{"url":"https://github.com/arthur-eudeline/payload-plugins/issues"},"dist":{"shasum":"a8d9edc3423b809c2a0651ddb3b9bb5354db53d7","tarball":"https://registry.npmjs.org/@arthur.eudeline/payload-plugin-mfa/-/payload-plugin-mfa-0.1.1.tgz","fileCount":72,"integrity":"sha512-2jPdWX4VcdwFiqa23jV2QkHYBxciQyhv8ooodpihlkSV9d7g7OZ/6tr1oAL1EwKzS+S6W28dEytR+h4CNu2uGw==","signatures":[{"sig":"MEYCIQDBeLfZu8dqGI0VEyrli//uDHttdzpfOoCIriIbrYQutAIhAL7p2OstcKpbnTCAhqGzZrUcOf2JdGAAUM8aki8UOulu","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":128784},"type":"module","engines":{"node":">=20.9.0"},"exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js","default":"./dist/index.js"},"./rsc":{"types":"./dist/exports/rsc.d.ts","import":"./dist/exports/rsc.js","default":"./dist/exports/rsc.js"},"./client":{"types":"./dist/exports/client.d.ts","import":"./dist/exports/client.js","default":"./dist/exports/client.js"}},"scripts":{"build":"rm -rf dist && tsc && node scripts/copy-assets.mjs","typecheck":"tsc --noEmit"},"_npmUser":{"name":"arthur.eudeline","email":"contact@arthur-eudeline.com"},"repository":{"url":"git+https://github.com/arthur-eudeline/payload-plugins.git","type":"git","directory":"packages/payload-plugin-mfa"},"description":"Two-factor authentication (TOTP + one-time backup codes) for the Payload CMS admin panel.","directories":{},"sideEffects":["*.css"],"_nodeVersion":"24.11.1","dependencies":{"qrcode":"^1.5.4","otpauth":"^9.5.1"},"publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"next":"16.3.2","react":"19.2.8","payload":"^3.88.0","typescript":"6.0.3","@types/node":"^26.2.0","@types/react":"19.2.18","@types/qrcode":"^1.5.6","@payloadcms/ui":"3.88.0","@payloadcms/next":"3.88.0"},"peerDependencies":{"next":"^15.0.0 || ^16.0.0","react":"^19.0.0","payload":"^3.88.0","@payloadcms/ui":"^3.88.0","@payloadcms/next":"^3.88.0"},"_npmOperationalInternal":{"tmp":"tmp/payload-plugin-mfa_0.1.1_1788702658688_0.6052556927966859","host":"s3://npm-registry-packages-npm-production"}},"0.1.2":{"name":"@arthur.eudeline/payload-plugin-mfa","version":"0.1.2","description":"Two-factor authentication (TOTP + one-time backup codes) for the Payload CMS admin panel.","keywords":["payload","payload-plugin","payloadcms","2fa","mfa","totp","authenticator"],"license":"MIT","author":"Arthur Eudeline","homepage":"https://github.com/arthur-eudeline/payload-plugins/tree/main/packages/payload-plugin-mfa#readme","repository":{"type":"git","url":"git+https://github.com/arthur-eudeline/payload-plugins.git","directory":"packages/payload-plugin-mfa"},"bugs":{"url":"https://github.com/arthur-eudeline/payload-plugins/issues"},"type":"module","sideEffects":["*.css"],"publishConfig":{"access":"public"},"exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js","default":"./dist/index.js"},"./client":{"types":"./dist/exports/client.d.ts","import":"./dist/exports/client.js","default":"./dist/exports/client.js"},"./rsc":{"types":"./dist/exports/rsc.d.ts","import":"./dist/exports/rsc.js","default":"./dist/exports/rsc.js"}},"dependencies":{"otpauth":"^9.5.1","qrcode":"^1.5.4"},"peerDependencies":{"@payloadcms/next":"^3.88.0","@payloadcms/ui":"^3.88.0","next":"^15.0.0 || ^16.0.0","payload":"^3.88.0","react":"^19.0.0"},"devDependencies":{"@payloadcms/next":"3.88.0","@payloadcms/ui":"3.88.0","@types/node":"^26.2.0","@types/qrcode":"^1.5.6","@types/react":"19.2.18","next":"16.3.2","payload":"^3.88.0","react":"19.2.8","typescript":"6.0.3"},"engines":{"node":">=20.9.0"},"scripts":{"build":"rm -rf dist && tsc && node scripts/copy-assets.mjs","typecheck":"tsc --noEmit"},"_nodeVersion":"24.11.1","_id":"@arthur.eudeline/payload-plugin-mfa@0.1.2","dist":{"integrity":"sha512-djGS0EpFlo+baNpTSM8A+746S5SX51QL7GszSRi092in1Z7aw179yVyRzYJrwzSzHl1Bd7GnHoc6tpC9pZJ9ZA==","shasum":"ea42266d83791168af883a58c49bea02b64944f5","tarball":"https://registry.npmjs.org/@arthur.eudeline/payload-plugin-mfa/-/payload-plugin-mfa-0.1.2.tgz","fileCount":72,"unpackedSize":128835,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEQCICRNKVY2wdanj5vdSyQIN87XG/7orzBW55NtijN9TdMaAiAzhahyAO0H0GsL6pR3WbbYIFPi8WGuez9Dm/J4+gb8yg=="}]},"_npmUser":{"name":"arthur.eudeline","email":"contact@arthur-eudeline.com"},"directories":{},"maintainers":[{"name":"arthur.eudeline","email":"contact@arthur-eudeline.com"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/payload-plugin-mfa_0.1.2_1788847445689_0.5797401978333534"},"_hasShrinkwrap":false}},"time":{"created":"2026-09-06T13:50:58.458Z","modified":"2026-09-08T06:04:06.019Z","0.1.1":"2026-09-06T13:50:58.853Z","0.1.2":"2026-09-08T06:04:05.842Z"},"bugs":{"url":"https://github.com/arthur-eudeline/payload-plugins/issues"},"author":"Arthur Eudeline","license":"MIT","homepage":"https://github.com/arthur-eudeline/payload-plugins/tree/main/packages/payload-plugin-mfa#readme","keywords":["payload","payload-plugin","payloadcms","2fa","mfa","totp","authenticator"],"repository":{"type":"git","url":"git+https://github.com/arthur-eudeline/payload-plugins.git","directory":"packages/payload-plugin-mfa"},"description":"Two-factor authentication (TOTP + one-time backup codes) for the Payload CMS admin panel.","maintainers":[{"name":"arthur.eudeline","email":"contact@arthur-eudeline.com"}],"readme":"# @arthur-eudeline/payload-plugin-mfa\n\nTwo-factor authentication for the [Payload CMS](https://payloadcms.com) 3 admin panel: TOTP\n(Google Authenticator, 1Password, Aegis…) plus one-time backup codes. Payload 3 ships nothing for\nthis — the plugin adds the fields, the endpoints, the login step and the enrolment UI.\n\n```bash\npnpm add @arthur-eudeline/payload-plugin-mfa\n```\n\n```ts\n// payload.config.ts\nimport { mfaPlugin } from '@arthur-eudeline/payload-plugin-mfa';\n\nexport default buildConfig({\n  admin: { user: 'users' },\n  collections: [Users /* … */],\n  plugins: [mfaPlugin({ issuer: 'Acme' })],\n});\n```\n\nThen, once:\n\n```bash\npnpm payload generate:types\npnpm payload generate:importmap\npnpm payload migrate:create   # the plugin adds columns\n```\n\nThat is the whole setup. Users enable 2FA themselves from `/admin/account`; the next sign-in asks\nfor a code.\n\n## What it does\n\n- **Enrolment** — a panel on the user's own account renders a QR code, confirms the secret with a\n  first valid code, then shows ten single-use backup codes once.\n- **Sign-in** — `/admin/login` is replaced by a two-step form: email + password first, then the\n  code, only for accounts that have 2FA on. Backup codes are accepted in the same field.\n- **Recovery** — `resetMfa()` for the one dead end the UI can't fix (phone _and_ backup codes lost).\n\n## Security properties\n\n- The TOTP secret is encrypted at rest with `payload.encrypt` (keyed on `PAYLOAD_SECRET`); backup\n  codes are stored as per-code salted scrypt hashes, never in clear.\n- Every MFA field denies `create`/`update` through the API and the secrets also deny `read`. The\n  only write path is the plugin's own endpoints, which use `overrideAccess`. A `PATCH` on\n  `/api/users/:id` from a stolen session can neither disable 2FA nor plant a chosen secret.\n- The last accepted TOTP timestep is recorded, so an intercepted code cannot be replayed within its\n  ±30 s validity window.\n- Five consecutive invalid codes lock the second factor for 15 minutes (both configurable). Payload's\n  own `maxLoginAttempts` does not cover this: a bad code rolls the login transaction back, taking\n  the attempt counter with it — which is why the counter is written from an `afterError` hook.\n- Every endpoint acts on `req.user` only. No account can enrol, unlock or disable another.\n- Disabling 2FA requires a valid TOTP code rather than the password: verifying a password outside\n  the login flow would mean reimplementing Payload's internal hashing (`authenticateLocalStrategy`\n  is not exported), and `payload.login()` would mint a stray session. Re-asking for the second\n  factor is the guarantee that matters here — it blocks a stolen session, which is what 2FA is for.\n\n## Options\n\n| Option              | Default                                 |                                                                                                                        |\n| ------------------- | --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |\n| `collections`       | `[config.admin.user]`                   | Auth collections to protect.                                                                                           |\n| `issuer`            | `admin.meta.titleSuffix` ?? `'Payload'` | Name shown in the authenticator app.                                                                                   |\n| `enabled`           | `true`                                  | `false` neutralises the behaviour but **keeps the fields**, so an already-migrated database doesn't lose its columns.  |\n| `fieldName`         | `'mfa'`                                 | Name of the field group added to the collection.                                                                       |\n| `backupCodeCount`   | `10`                                    | Backup codes issued on activation.                                                                                     |\n| `maxAttempts`       | `5`                                     | Consecutive invalid codes before locking.                                                                              |\n| `lockDuration`      | `900000`                                | Lock duration, in milliseconds.                                                                                        |\n| `overrideLoginView` | `true`                                  | Set `false` to keep your own login view — then mount `MfaLoginForm` yourself, or handle the `MFA_REQUIRED` error code. |\n| `labels`            | —                                       | Per-language label overrides: `{ fr: { panelTitle: '…' } }`.                                                           |\n| `language`          | admin language                          | Pins the label language instead of following the admin's.                                                              |\n\nEnglish and French are built in; any other admin language falls back to English.\n\n## HTTP surface\n\nAll five are mounted on the protected collection and act on the caller only.\n\n|                                           |                                                                                                            |\n| ----------------------------------------- | ---------------------------------------------------------------------------------------------------------- |\n| `GET /api/<collection>/mfa/status`        | `{ enabled, confirmedAt, backupCodesRemaining, hasPendingSecret }`                                         |\n| `POST /api/<collection>/mfa/setup`        | → `{ secret, qrCode }` (data URL). Stores a _pending_ secret; sign-in is unaffected until it is confirmed. |\n| `POST /api/<collection>/mfa/activate`     | `{ code }` → `{ backupCodes }`. The only time backup codes are readable.                                   |\n| `POST /api/<collection>/mfa/backup-codes` | `{ code }` → `{ backupCodes }`. Invalidates the previous set.                                              |\n| `POST /api/<collection>/mfa/disable`      | `{ code }` → `{ enabled: false }`                                                                          |\n\nOn sign-in, `POST /api/<collection>/login` accepts an extra `mfaCode` field. Without it, an enrolled\naccount gets `401` with `errors[0].data.code === 'MFA_REQUIRED'`; a wrong code gives `MFA_INVALID`,\nand a locked account `MFA_LOCKED` (`429`). Those constants are exported as `MFA_ERROR_CODES`.\n\n> **Note for plugin authors.** The error codes above are delivered through `formatErrors`'s\n> `Array.isArray(message)` branch rather than an `APIError`'s `data` field. Payload only forwards\n> `data` for an error it recognises with `instanceof APIError`, and that check fails in a Next.js\n> production build — `withPayload` externalises `payload` only in development, so the bundled copy\n> the plugin throws from is not the one `formatErrors` compares against. Going through the array\n> branch is identity-independent and behaves the same in dev and in production.\n\n## Recovery\n\n```ts\nimport { resetMfa } from '@arthur-eudeline/payload-plugin-mfa';\n\nawait resetMfa({ payload, email: 'someone@example.com' });\n```\n\nServer-side and privileged — call it from an admin script, never from an exposed route.\n\n## Exports\n\n|                                              |                                                                    |\n| -------------------------------------------- | ------------------------------------------------------------------ |\n| `@arthur-eudeline/payload-plugin-mfa`        | `mfaPlugin`, `resetMfa`, `MFA_ERROR_CODES`, `builtInLabels`, types |\n| `@arthur-eudeline/payload-plugin-mfa/client` | `MfaPanel`, `MfaLoginForm`, `resolveLabels`                        |\n| `@arthur-eudeline/payload-plugin-mfa/rsc`    | `MfaLoginView`                                                     |\n\n## Requirements\n\nPayload 3.88+, React 19, Next 15 or 16. The database adapter must support transactions (the\nPostgres, SQLite and MongoDB adapters all do).\n\n## License\n\nMIT\n","readmeFilename":""}