Skip to content

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-pro feature. 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.

MethodUsed for
TOTPA 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 codesEight single-use codes shown when TOTP is turned on. Each one works once in place of a code.
Passkeys and security keysA 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.

  1. TOTP and backup codes need nothing more. For passkeys, run with a license that carries mfa-pro. See licensing and tiers.
  2. For passkeys, set WEBAUTHN_RPID to the domain the admin console is served from, and WEBAUTHN_RP_ORIGINS to its origin. Set them before anyone registers a passkey.
  3. Restart the instance.

In the admin console, each person enrolls their own account:

  1. Open the account menu in the header and choose Two-factor authentication.
  2. Choose Turn on two-factor authentication.
  3. Scan the QR code with an authenticator app, or copy the secret.
  4. Enter the six-digit code and choose Verify and turn on.
  5. 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.

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.

  1. 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": [] }
  2. 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 secret to an authenticator app, or show provisioning_uri as a QR code.

  3. 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 422 with invalid TOTP code.

  4. Sign in again. The answer is now the challenge shown above. Copy challenge_token into CHALLENGE.

  5. 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 token and refresh_token. Sending the same challenge again answers 401 with The MFA challenge has expired. Please log in again.

  6. 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.

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.

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:

  1. Call the begin route. It returns a session_id and the options to pass to the browser's navigator.credentials.create() or navigator.credentials.get().
  2. Send the browser's credential as the JSON body of the complete route, 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.

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:

Terminal window
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.

VariableWhat it doesDefault
WEBAUTHN_RPIDThe domain passkeys are bound to, such as admin.example.com.DOMAIN, then localhost
WEBAUTHN_RP_DISPLAY_NAMEThe name an authenticator shows for your instance.LyEve
WEBAUTHN_RP_ORIGINSComma-separated origins allowed to use passkeys.https://<rpid> and http://localhost:5173
MFA_TOTP_ISSUERThe name shown against the entry in an authenticator app.the display name
PUBLIC_URLBase 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.

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
MethodPathWhoPurpose
GET/api/admin/mfa/statussigned inWhether MFA is on, and the person's passkeys
POST/api/admin/mfa/totp/setupsigned inStart TOTP enrollment
POST/api/admin/mfa/totp/verifysigned inConfirm enrollment with a code and get backup codes
POST/api/admin/mfa/totp/disablesigned inTurn TOTP off with a current code or a backup code. 204
POST/api/admin/mfa/backup-codes/regeneratesigned inReplace the backup codes
POST/api/admin/mfa/webauthn/register/beginsigned inStart registering a passkey
POST/api/admin/mfa/webauthn/register/completesigned inFinish registering a passkey. 201
POST/api/admin/mfa/webauthn/auth/beginsigned inStart a passkey step-up check
POST/api/admin/mfa/webauthn/auth/completesigned inFinish a passkey step-up check
GET/api/admin/mfa/webauthn/credentialssigned inList the person's passkeys
PUT/api/admin/mfa/webauthn/credentials/{id}/namesigned inRename a passkey
PUT/api/admin/mfa/webauthn/credentials/{id}/backupsigned inMark or unmark a passkey as the backup key
DELETE/api/admin/mfa/webauthn/credentials/{id}signed inRemove a passkey
POST/api/admin/mfa/admin-resetsuper_adminTurn off another person's MFA
POST/api/admin/auth/mfa-verifypublicExchange a challenge and a code for a session
POST/api/admin/auth/webauthn/login/beginpublicStart passwordless sign-in
POST/api/admin/auth/webauthn/login/completepublicFinish passwordless sign-in and get a token
GET/api/admin/auth/webauthn/conditionalpublicStart a sign-in offered through browser autofill
POST/api/admin/auth/webauthn/recovery/beginpublicStart a sign-in with the backup security key
POST/api/admin/auth/webauthn/recovery/completepublicFinish a recovery sign-in
POST/api/admin/auth/webauthn/cross-device/startpublicStart a cross-device sign-in and get a QR code URL
GET/api/admin/auth/webauthn/cross-device/{sessionID}/statuspublicPoll a cross-device sign-in
GET/api/admin/auth/webauthn/cross-device/{sessionID}/qrpublicFetch the QR code payload
POST/api/admin/auth/webauthn/cross-device/completepublicApprove a cross-device sign-in from the phone

At sign-in (POST /api/admin/auth/mfa-verify):

StatusMessageCause
422The provided verification code is invalid.The code is wrong and is not an unused backup code.
429Too 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:

StatusMessageCause
422invalid TOTP code or invalid codeThe code is wrong or was already used.
429too many verification attempts, try again laterMore than 5 code checks in 5 minutes.
429account temporarily locked due to too many failed attempts10 failures in a row lock code checks for 30 minutes.
Every other error
StatusMessageCause
429too many setup attempts, try again laterMore than 3 setup calls in an hour.
400no pending TOTP enrollmentVerify was called before setup on an account that never started enrollment. After a turn-off, the same call answers 503 with mfa verification error.
400MFA not enabledDisable or regenerate was called on an account without MFA.
401The MFA challenge has expired. Please log in again.The challenge was used already or ran out.
403admin-reset requires platform-level super_admin (no tenant scope)A super admin acting in one tenant called the reset.
503WebAuthn not configuredA passkey route was called while passkeys cannot run.
402payment_requiredRegistering a passkey, or marking one as the backup key, without mfa-pro.
  • The browser refuses to create a passkey. WEBAUTHN_RPID must be the console's domain or a parent of it, and the page's origin must be in WEBAUTHN_RP_ORIGINS.
  • The QR code points at https://localhost. Set PUBLIC_URL to the instance's public address.
  • Someone is locked out of every factor. A super admin resets their MFA, and they enroll again.