calmfox / magento-admin-two-factor-module
Two-factor authentication for Magento administrators: a passkey or an authenticator app, with a panel-wide policy and per-administrator reset.
Package info
github.com/calmfoxpl/magento-admin-two-factor-module
Type:magento2-module
pkg:composer/calmfox/magento-admin-two-factor-module
Requires
- php: ^8.2
- ext-json: *
- ext-openssl: *
- bacon/bacon-qr-code: ^3.0
- lbuchs/webauthn: ^2.2
- magento/framework: ^103.0
- magento/module-authorization: ^100.4
- magento/module-backend: ^102.0
- magento/module-config: ^101.2
- magento/module-store: ^101.1
- magento/module-user: ^101.2
Requires (Dev)
- phpunit/phpunit: ^10.5 || ^11.0 || ^12.0
Suggests
- calmfox/magento-admin-invitation-module: Invited administrators pair a second factor right after choosing their password
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-18 20:40:21 UTC
README
Two-factor authentication for the Magento 2 administration panel. Each administrator chooses their second factor: a passkey (fingerprint, face or device PIN) or an authenticator app (TOTP).
Features
-
A choice at setup: the passkey is offered first, because it cannot be phished and there is nothing to retype. An authenticator app, with a QR code or a key to type by hand, comes next.
-
At sign-in: after the password, a passkey takes one gesture and an app takes six digits. An administrator with both can switch between them.
-
A policy in the panel: Stores → Configuration → Calmfox → Two-Factor Authentication:
- required: every administrator must pair something; until they do they only reach the setup page,
- optional: each administrator decides,
- turned off: nobody is asked, and anything paired is kept for when the policy changes back.
-
Per administrator: a Two-Step Verification tab on their page lists the paired app and passkeys, with two separate actions:
- Reset: removes everything paired and demands a new pairing at the next sign-in, whatever the policy says. For a lost phone.
- Turn off: removes everything paired and leaves the rest to the policy.
The same is available as
bin/magento calmfox:admin:2fa:reset <email> [--disable]. -
A column on the grid: System → Permissions → All Users shows who is covered and who is not.
-
Careful with secrets: the authenticator secret is stored encrypted with Magento's own encryptor and only lands on the account after a code it produced has been typed; a passkey is stored only after its signature has been verified, and only as a public key.
-
Careful with passkeys:
- user verification is required,
- challenges are used once and bound to the session,
- origins are checked strictly rather than by suffix,
- only the account's own keys are accepted,
- the signature counter is remembered, so a replayed response is noticed.
Built on lbuchs/webauthn, which has no dependencies of its own.
-
Invitations: with calmfox/magento-admin-invitation-module installed, a newly invited administrator pairs a second factor right after choosing their password.
-
The web API too: a password on its own no longer earns an administrator token. See The web API.
-
Translations: English and Polish.
Requirements
| Version | |
|---|---|
| Magento | 2.4.6, 2.4.7 (Open Source and Adobe Commerce) |
| PHP | 8.2, 8.3, 8.4 |
| Browser for passkeys | any current browser, over https (plain http works on localhost only) |
This module does not use, and does not conflict with, Magento_TwoFactorAuth. Running both would ask administrators for two second factors; pick one.
Installation
composer require calmfox/magento-admin-two-factor-module
bin/magento module:enable Calmfox_AdminTwoFactor
bin/magento setup:upgrade
bin/magento setup:di:compile # production mode only
bin/magento cache:flush
The upgrade adds one table, calmfox_admin_two_factor: one row per administrator, holding the encrypted authenticator secret, the paired passkeys and whether a new pairing is due. Deleting an administrator deletes the row.
Installing from a package into app/code instead? Magento does not read a module's own composer.json there, so the two libraries have to be required by the project:
composer require bacon/bacon-qr-code lbuchs/webauthn mkdir -p app/code/Calmfox unzip magento-admin-two-factor-module.zip -d app/code/Calmfox mv app/code/Calmfox/magento-admin-two-factor-module app/code/Calmfox/AdminTwoFactor bin/magento module:enable Calmfox_AdminTwoFactor bin/magento setup:upgrade bin/magento cache:flush
Before you turn the policy to required on a live shop, pair a second factor on your own account first. Otherwise every administrator, including you, is sent to the setup page at their next sign-in — which works, but is a surprise. bin/magento calmfox:admin:2fa:reset is the way back if something goes wrong.
Configuration
Stores → Configuration → Calmfox → Two-Factor Authentication.
| Setting | Default | What it does |
|---|---|---|
| Who Must Use a Second Factor | Required | The policy described above. |
| Offer Passkeys | Yes | Turn off to leave only the authenticator app. |
| Name Shown by the Device | store name | What the phone or laptop calls this site when creating a passkey. |
| Domain Passkeys Are Bound To | host of the request | A bare domain. Changing it makes every paired passkey stop working. |
| Name Shown in the App | store name | The account label in the authenticator app. |
| Clock Drift Allowed (steps) | 1 | 30-second steps either side of now that a code is still accepted. |
How it works
The guard is an observer on controller_action_predispatch, not on the sign-in itself, because the question is not "did you just sign in" but "may you be on this page at all". A session that never answered its challenge is stopped on every request, including one opened from a bookmark hours later. Its own pages, plus signing out and password recovery, are always allowed, so nobody who cannot answer is trapped.
What counts as answered lives in a session of its own rather than on the admin session, so that signing out, or Magento renewing the admin session, cannot leave a stale "already answered" behind. It records which administrator answered, so it cannot be inherited by whoever signs in next on the same browser.
One-time passwords are RFC 6238 over RFC 4226 with the parameters every authenticator app uses: SHA-1, a 30-second step, six digits. They are implemented in this package rather than pulled in as a dependency — it is forty lines of arithmetic, and the RFC's own test vectors are in the test suite, so an app anywhere in the world and this module agree by construction.
A code is spent once. A code is right for as long as its thirty-second step lasts, and a step either side of now is accepted for clocks that drift, so the same six digits would otherwise answer twice: read off a screen share, seen over a shoulder, or replayed from a captured request. The newest step an account has used is recorded, and anything from that step or earlier is refused however right the arithmetic is. The code typed to pair the app counts as spent too, so it cannot also answer the first challenge. The record is claimed with a single conditional statement rather than a read and then a write, because two requests arriving together with the same code would both pass the latter.
Passkeys are the one second factor phishing cannot relay: the signature covers the address of the site, so a look-alike page never gets a valid one. Six digits can be retyped into a fake form; a passkey cannot. The library accepts any host that merely ends with the configured domain, so the module checks the origin itself: the exact site or a real subdomain, over https, with plain http allowed only on localhost as browsers do.
Setting up leaves nothing half-done. The authenticator secret waits in the session and only reaches the account once a code it produced has been typed; a passkey is stored only once its signature verifies. An abandoned setup leaves no secret behind, and nobody is locked out by a secret their app never had.
Appearance
- Pages: the challenge and the setup page reuse the admin sign-in screen, logo and all, so an admin theme applies to them. Override the templates under
Calmfox_AdminTwoFactor::as with any Magento template:challenge/form.phtml,setup/form.phtml,user/two_factor.phtml. - CSS: the setup choices carry
calmfox-choice-passkeyandcalmfox-choice-app; the grid column carriescalmfox-2fa-on,calmfox-2fa-pendingandcalmfox-2fa-off. - Texts: override the strings in
i18n/.
Recovering an account
If an administrator loses their phone, any colleague with the Two-Factor Authentication permission resets it from their page. If nobody can sign in at all:
bin/magento calmfox:admin:2fa:reset ewa@example.com
They keep their password and pair a new device at the next sign-in. --disable removes what is paired without demanding a new pairing, and the policy then decides.
The web API
Magento has a second door to the same permissions. POST /V1/integration/admin/token trades a user name and a password for a token that carries whatever the account may do, and that request never reaches an admin controller, so the panel's guard does not see it. A second factor on the panel alone would leave that door open.
The module closes it. Under Stores > Configuration > Calmfox > Two-Factor Authentication > Web API:
-
Ask for a code as well (the default). An administrator covered by the policy gets nothing from Magento's endpoint. They post to
/V1/calmfox-two-factor/admin-tokeninstead, with a code alongside the password, and get back an ordinary Magento token:curl -X POST https://example.com/rest/V1/calmfox-two-factor/admin-token \ -H 'Content-Type: application/json' \ -d '{"username":"anna","password":"...","code":"123456"}'
-
Issue no administrator tokens. Nothing is handed out, for a shop where nothing calls it.
-
Password alone, as Magento does it. The escape hatch for a shop that has to keep an older integration alive and shuts this door somewhere else.
Three things worth knowing:
- Integrations are unaffected. Those created under System > Extensions > Integrations carry their own tokens, tied to no person's password, and none of this touches them.
- A passkey cannot answer here. It needs a browser and someone present to make a gesture. An administrator who guards the panel with a passkey pairs an authenticator app as well if they, or a script of theirs, need the API.
- The refusal is not an oracle. The password is checked before anything is refused, so the message that names two-factor authentication only ever reaches someone who already had the password. A wrong password falls through to Magento untouched, and is counted and locked out as always.
What this does not cover
Trusted devices. Every sign-in is challenged. There is no "remember this browser for 30 days".
Development
The unit tests cover the framework-free core — the one-time passwords against the RFC's own test vectors, the base32 they travel in, the policy decisions and the passkey bookkeeping — plus the wiring of the XML the module is assembled from. They need neither Magento nor a database:
vendor/bin/phpunit -c phpunit.xml.dist
Any PHPUnit 10 or newer works; the package needs no composer install of its own.
Security
See SECURITY.md for how to report a vulnerability.