Multi-factor authentication
Included free on every install, with authenticator apps, backup codes and signing in with a passkey already enrolled. Enrolling a passkey and marking one as the backup key need a license with the
mfa-profeature. See pricing.
Multi-factor authentication adds a second step to admin sign-in. After the password, the person enters a code from an authenticator app or uses a passkey, so a stolen password alone does not open the account. Each person turns it on for their own account.
How it works
Section titled “How it works”| Method | Used for |
|---|---|
| TOTP | A six-digit code that changes every 30 seconds. Turning TOTP on or off and replacing backup codes accept each code once. At sign-in, a code still works again while it is current. |
| Backup codes | Eight single-use codes shown when TOTP is turned on. Each one works once in place of a code. |
| Passkeys and security keys | A second step, a sign-in without a password, or, for a key marked as the backup, a recovery sign-in. Enrolling one needs mfa-pro. |
When an account has TOTP on, POST /api/admin/auth/login does not return a
session. It returns a challenge:
{ "mfa_required": true, "challenge_token": "eyJhbGciOi...", "mfa_methods": ["totp", "webauthn"]}mfa_methods lists webauthn only when the person has a passkey. The
challenge is exchanged for a session at POST /api/admin/auth/mfa-verify. Each
challenge works once and expires five minutes after it is issued. TOTP secrets are encrypted at rest with the
instance's ENCRYPTION_KEY.
Turn it on
Section titled “Turn it on”- TOTP and backup codes need nothing more. For passkeys, run with a license
that carries
mfa-pro. See licensing and tiers. - For passkeys, set
WEBAUTHN_RPIDto the domain the admin console is served from, andWEBAUTHN_RP_ORIGINSto its origin. Set them before anyone registers a passkey. - Restart the instance.
In the admin console, each person enrolls their own account:
- Open the account menu in the header and choose Two-factor authentication.
- Choose Turn on two-factor authentication.
- Scan the QR code with an authenticator app, or copy the secret.
- Enter the six-digit code and choose Verify and turn on.
- Save the eight backup codes. They are shown once.
To turn it off, enter a current code or a backup code on the same page and choose Turn off.
Try it
Section titled “Try it”This enrolls an authenticator app and signs in with it. Use an account of your
own, and keep its token in TOKEN. The
quickstart shows how to get one.
-
Check the account's state:
Terminal window curl http://localhost:3001/api/admin/mfa/status \-H "Authorization: Bearer $TOKEN"{ "enabled": false, "has_passkeys": false, "passkeys": [] } -
Start enrollment:
Terminal window curl -X POST http://localhost:3001/api/admin/mfa/totp/setup \-H "Authorization: Bearer $TOKEN"{"provisioning_uri": "otpauth://totp/LyEve:ada@example.com?algorithm=SHA1&digits=6&issuer=LyEve&period=30&secret=NY3NKSCE3CGYD7OHLXACNIGQZXREXM6Y","secret": "NY3NKSCE3CGYD7OHLXACNIGQZXREXM6Y"}Add
secretto an authenticator app, or showprovisioning_urias a QR code. -
Confirm with the code the app shows:
Terminal window curl -X POST http://localhost:3001/api/admin/mfa/totp/verify \-H "Authorization: Bearer $TOKEN" \-H "Content-Type: application/json" \-d '{"code": "123456"}'{ "enabled": true, "backup_codes": ["cee0a58bbf71c80d", "4be04c5798897db9", "..."] }Save the eight backup codes now. A wrong code answers
422withinvalid TOTP code. -
Sign in again. The answer is now the challenge shown above. Copy
challenge_tokenintoCHALLENGE. -
Finish the sign-in with a code from the app, or with a backup code:
Terminal window curl -X POST http://localhost:3001/api/admin/auth/mfa-verify \-H "Content-Type: application/json" \-d '{"challenge_token": "'"$CHALLENGE"'", "code": "123456"}'The answer is a normal sign-in, with
tokenandrefresh_token. Sending the same challenge again answers401withThe MFA challenge has expired. Please log in again. -
Turn it off with a current code or a backup code:
Terminal window curl -X POST http://localhost:3001/api/admin/mfa/totp/disable \-H "Authorization: Bearer $TOKEN" \-H "Content-Type: application/json" \-d '{"code": "7cfaca44e9aae775"}'The answer is
204.
Replace the backup codes
Section titled “Replace the backup codes”POST /api/admin/mfa/backup-codes/regenerate with a current code in
{"code": "123456"} replaces all eight with new ones and returns them as
backup_codes. A code from the same 30-second window as the last code that
turning TOTP on or off or replacing backup codes accepted is refused, so wait
for the app to show the next code.
Use passkeys
Section titled “Use passkeys”Registering a passkey and marking one as the backup key need mfa-pro. Every
sign-in with a passkey stays free, so a passkey enrolled while the license
covered it keeps working after a lapse, through step-up, passwordless,
cross-device and recovery sign-in alike. Listing, renaming and removing a
passkey, and clearing its backup mark, are free too. Without mfa-pro the
register routes answer
402 with {"error": "payment_required", "plugin": "mfa", "feature": "feature:mfa-pro", "upgrade_url": ""}.
Every WebAuthn route comes in a begin and complete pair:
- Call the
beginroute. It returns asession_idand theoptionsto pass to the browser'snavigator.credentials.create()ornavigator.credentials.get(). - Send the browser's credential as the JSON body of the
completeroute, with?session_id=<session_id>in the query.
Registration takes an optional &name= in the query of the complete call, and
answers 201 with {"registered": true}. Passwordless sign-in answers with a
session:
{ "token": "eyJhbGciOi...", "user_id": "5f0c...", "email": "ada@example.com", "expires_in": 900}The token lives for JWT_EXPIRY_SECS.
Mark a passkey as the backup key with
PUT /api/admin/mfa/webauthn/credentials/{id}/backup and the body
{"is_backup": true}. Recovery sign-in accepts only a backup key, and its
begin call answers 400 no backup security key registered when the person has
none.
A cross-device sign-in starts at
POST /api/admin/auth/webauthn/cross-device/start, which returns a
session_id, a qrcode_url and expires_at. It expires two minutes after it
starts.
Reset someone's MFA
Section titled “Reset someone's MFA”A super admin who acts across every tenant can turn off another person's MFA, for example when they lost their phone and their backup codes:
curl -X POST http://localhost:3001/api/admin/mfa/admin-reset \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"user_id": "5f0c7b1e-1d2a-4c3b-9e8f-0a1b2c3d4e5f", "reason": "Lost device, identity checked by phone"}'{ "disabled": true, "target_user": "5f0c7b1e-1d2a-4c3b-9e8f-0a1b2c3d4e5f" }reason is required and recorded with the reset. The person then enrolls
again.
Settings
Section titled “Settings”| Variable | What it does | Default |
|---|---|---|
WEBAUTHN_RPID | The domain passkeys are bound to, such as admin.example.com. | DOMAIN, then localhost |
WEBAUTHN_RP_DISPLAY_NAME | The name an authenticator shows for your instance. | LyEve |
WEBAUTHN_RP_ORIGINS | Comma-separated origins allowed to use passkeys. | https://<rpid> and http://localhost:5173 |
MFA_TOTP_ISSUER | The name shown against the entry in an authenticator app. | the display name |
PUBLIC_URL | Base URL for the cross-device QR code when the request has no Origin header. | https://localhost |
Changing WEBAUTHN_RPID makes every passkey already registered unusable. The
relying party settings take effect at restart. The issuer name and the token
lifetime take effect without one. If you set LYEVE_PLUGINS, include mfa in
it.
Routes
Section titled “Routes”The routes under /api/admin/mfa act on the signed-in person and need a
session. An admin token is refused on all of them. The routes under
/api/admin/auth/webauthn run before a session exists, so they are public,
each with its own rate limit.
Enrollment, passkeys and sign-in routes
| Method | Path | Who | Purpose |
|---|---|---|---|
GET | /api/admin/mfa/status | signed in | Whether MFA is on, and the person's passkeys |
POST | /api/admin/mfa/totp/setup | signed in | Start TOTP enrollment |
POST | /api/admin/mfa/totp/verify | signed in | Confirm enrollment with a code and get backup codes |
POST | /api/admin/mfa/totp/disable | signed in | Turn TOTP off with a current code or a backup code. 204 |
POST | /api/admin/mfa/backup-codes/regenerate | signed in | Replace the backup codes |
POST | /api/admin/mfa/webauthn/register/begin | signed in | Start registering a passkey |
POST | /api/admin/mfa/webauthn/register/complete | signed in | Finish registering a passkey. 201 |
POST | /api/admin/mfa/webauthn/auth/begin | signed in | Start a passkey step-up check |
POST | /api/admin/mfa/webauthn/auth/complete | signed in | Finish a passkey step-up check |
GET | /api/admin/mfa/webauthn/credentials | signed in | List the person's passkeys |
PUT | /api/admin/mfa/webauthn/credentials/{id}/name | signed in | Rename a passkey |
PUT | /api/admin/mfa/webauthn/credentials/{id}/backup | signed in | Mark or unmark a passkey as the backup key |
DELETE | /api/admin/mfa/webauthn/credentials/{id} | signed in | Remove a passkey |
POST | /api/admin/mfa/admin-reset | super_admin | Turn off another person's MFA |
POST | /api/admin/auth/mfa-verify | public | Exchange a challenge and a code for a session |
POST | /api/admin/auth/webauthn/login/begin | public | Start passwordless sign-in |
POST | /api/admin/auth/webauthn/login/complete | public | Finish passwordless sign-in and get a token |
GET | /api/admin/auth/webauthn/conditional | public | Start a sign-in offered through browser autofill |
POST | /api/admin/auth/webauthn/recovery/begin | public | Start a sign-in with the backup security key |
POST | /api/admin/auth/webauthn/recovery/complete | public | Finish a recovery sign-in |
POST | /api/admin/auth/webauthn/cross-device/start | public | Start a cross-device sign-in and get a QR code URL |
GET | /api/admin/auth/webauthn/cross-device/{sessionID}/status | public | Poll a cross-device sign-in |
GET | /api/admin/auth/webauthn/cross-device/{sessionID}/qr | public | Fetch the QR code payload |
POST | /api/admin/auth/webauthn/cross-device/complete | public | Approve a cross-device sign-in from the phone |
Limits and errors
Section titled “Limits and errors”At sign-in (POST /api/admin/auth/mfa-verify):
| Status | Message | Cause |
|---|---|---|
422 | The provided verification code is invalid. | The code is wrong and is not an unused backup code. |
429 | Too many MFA verification attempts. Please log in again to receive a new challenge. | 5 wrong codes for the account within 15 minutes, or 5 tries on one challenge. |
On the routes under /api/admin/mfa:
| Status | Message | Cause |
|---|---|---|
422 | invalid TOTP code or invalid code | The code is wrong or was already used. |
429 | too many verification attempts, try again later | More than 5 code checks in 5 minutes. |
429 | account temporarily locked due to too many failed attempts | 10 failures in a row lock code checks for 30 minutes. |
Every other error
| Status | Message | Cause |
|---|---|---|
429 | too many setup attempts, try again later | More than 3 setup calls in an hour. |
400 | no pending TOTP enrollment | Verify was called before setup on an account that never started enrollment. After a turn-off, the same call answers 503 with mfa verification error. |
400 | MFA not enabled | Disable or regenerate was called on an account without MFA. |
401 | The MFA challenge has expired. Please log in again. | The challenge was used already or ran out. |
403 | admin-reset requires platform-level super_admin (no tenant scope) | A super admin acting in one tenant called the reset. |
503 | WebAuthn not configured | A passkey route was called while passkeys cannot run. |
402 | payment_required | Registering a passkey, or marking one as the backup key, without mfa-pro. |
Troubleshooting
Section titled “Troubleshooting”- The browser refuses to create a passkey.
WEBAUTHN_RPIDmust be the console's domain or a parent of it, and the page's origin must be inWEBAUTHN_RP_ORIGINS. - The QR code points at
https://localhost. SetPUBLIC_URLto the instance's public address. - Someone is locked out of every factor. A super admin resets their MFA, and they enroll again.
Related
Section titled “Related”- Sign-in options: every way to sign in, side by side.
- Trusted devices: ask for the second step only when a sign-in looks risky.
- Admin tokens: creating one asks for an MFA code when the account has MFA.
- Harden your instance.