Skip to main content

MFA

# Self-service (the signed-in user)
GET /orgs/{orgId}/api/v1/me/authenticators
POST /orgs/{orgId}/api/v1/me/authenticators/{type}/enroll
POST /orgs/{orgId}/api/v1/me/authenticators/{id}/verify
PATCH /orgs/{orgId}/api/v1/me/authenticators/{id}
DELETE /orgs/{orgId}/api/v1/me/authenticators/{id}
GET /orgs/{orgId}/api/v1/me/recovery-codes
POST /orgs/{orgId}/api/v1/me/recovery-codes
GET /orgs/{orgId}/api/v1/me/trusted-devices
DELETE /orgs/{orgId}/api/v1/me/trusted-devices/{id}

# Challenges and step-up
POST /orgs/{orgId}/api/v1/mfa/challenges
GET /orgs/{orgId}/api/v1/mfa/challenges/{id}
POST /orgs/{orgId}/api/v1/mfa/challenges/{id}/verify

# Admin
GET /orgs/{orgId}/api/v1/admin/users/{userId}/authenticators
DELETE /orgs/{orgId}/api/v1/admin/users/{userId}/authenticators/{authenticatorId}
POST /orgs/{orgId}/api/v1/admin/users/{userId}/temporary-access-code
GET /orgs/{orgId}/api/v1/admin/policies/mfa
PUT /orgs/{orgId}/api/v1/admin/policies/mfa
GET /orgs/{orgId}/api/v1/admin/reports/mfa-coverage
POST /orgs/{orgId}/api/v1/admin/users/{userId}/mfa/reset (removed — 410 Gone)

These are the same operations the hosted Secure your account, MFA challenge and Sign-in & security pages use, so a custom UI can implement every MFA flow. For the concepts behind them (factor tiers, requirement modes, recovery), read Multi-Factor Authentication first.

In the generated SDKs the operations are tagged Mfa and Admin: Mfa. Operation ids: listMyAuthenticators, enrollAuthenticator, verifyAuthenticator, updateAuthenticator, deleteAuthenticator, getRecoveryCodeStatus, generateRecoveryCodes, listTrustedDevices, revokeTrustedDevice, createMfaChallenge, getMfaChallenge, verifyMfaChallenge, listUserAuthenticators, deleteUserAuthenticator, issueTemporaryAccessCode, getMfaPolicy, updateMfaPolicy, getMfaCoverageReport and the deprecated resetUserMfa.

Authentication​

EndpointsCredential
/me/* and /mfa/challenges*The user's OAuth access token (Authorization: Bearer …) carrying the scope below, or a signed-in browser session on the LumoAuth domain
/admin/*An admin API key (X-API-Key: lmk_… or Authorization: ApiKey lmk_…) or a Bearer token with an admin scope, plus the permission in the table below. API-key scopes: admin:users:read (list authenticators, coverage), admin:users:write (remove authenticator, temporary access code), admin:settings:read / admin:settings:write (policy).

An access token must carry an explicit user scope; a token with only openid profile email gets 403 forbidden:

EndpointsScope
GET /me/authenticators, GET /me/recovery-codes, GET /me/trusted-devicesmfa:read or mfa:manage
Every other /me/authenticators*, /me/recovery-codes, /me/trusted-devices/{id} callmfa:manage
/mfa/challenges*mfa:challenge or mfa:manage

POST /me/authenticators/{type}/enroll and POST /me/recovery-codes additionally require the user to have signed in within the last 10 minutes: the token's auth_time claim must be at most 600 seconds old. An older token gets:

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="insufficient_user_authentication", error_description="A more recent authentication is required", max_age=600
{ "error": "insufficient_user_authentication", "message": "This action requires the user to have signed in within the last 600 seconds; obtain a new access token with max_age=600.", "status": 401 }

Send the user through /authorize again with max_age=600 (or prompt=login) and retry with the new token.

Standard

The step-up authentication challenge standard defines the insufficient_user_authentication error and the max_age / acr_values challenge parameters a resource server returns when the token's authentication event is too old or too weak. To comply, read max_age from the WWW-Authenticate header, start a new authorization request with that max_age, and retry with the fresh token. Read the standard.

Admin endpoints need a permission as well as the scope. A session or Bearer caller must hold it; for an API key, the key's creator must. Removing a user's authenticator and issuing a temporary access code also require that you hold every permission the target user holds (403 otherwise). See Required permissions.

MethodEndpointPermissionAPI-key / token scope
GET/admin/users/{userId}/authenticatorsusers.viewadmin:users:read
DELETE/admin/users/{userId}/authenticators/{authenticatorId}users.editadmin:users:write
POST/admin/users/{userId}/temporary-access-codeusers.editadmin:users:write
GET/admin/policies/mfasettings.viewadmin:settings:read
PUT/admin/policies/mfasettings.manageadmin:settings:write
GET/admin/reports/mfa-coverageusers.viewadmin:users:read

Authenticator ids​

Authenticators are addressed by a reference {type}:{id}:

TypeExampleNotes
passkeypasskey:7Tier 1
pushpush:5Tier 2 — a LumoAuth app device
totptotp:12Tier 3
sms_otpsms_otp:13Tier 4
email_otpemail_otp:14Tier 4; counts as a second factor only when the policy allows it
recovery_coderecovery_code:15Recovery only

Two temporary ids appear during enrollment: passkey:pending and push_enrollment:<token>.

The authenticator object​

{
"id": "totp:12",
"type": "totp",
"display_name": "Authenticator app",
"state": "active",
"is_default": true,
"tier": 3,
"tier_label": "Standard",
"amr": ["otp"],
"created_at": "2026-09-24T10:00:00+00:00",
"activated_at": "2026-09-24T10:00:30+00:00",
"last_used_at": "2026-09-24T12:41:07+00:00",
"last_used_context": {"ip": "203.0.113.4", "geo": "Berlin, DE", "user_agent": "Mozilla/5.0 …"},
"metadata": {"algorithm": "SHA1", "digits": 6, "period": 30}
}
FieldDescription
statepending (enrollment not verified yet), active, suspended
tier / tier_label1 Strongest, 2 Strong, 3 Standard, 4 Basic, 5 Recovery
amrAuthentication method reference values this factor adds to tokens: passkey ["hwk","user"], push ["swk","user"], totp ["otp"], sms_otp ["sms"], email_otp and recovery_code ["otp"]
last_used_contextIP, coarse location and user agent of the last successful use, or null
metadataPer type — totp: algorithm, digits, period; sms_otp: masked_phone, country, line_type, verified_at; email_otp: email (masked); push: platform, device_name, offline_codes; passkey: aaguid, authenticator, certification, backup_eligible, backed_up, transports; recovery_code: remaining, generated_at

Secrets (TOTP seeds, phone numbers in full, device keys) are never returned after enrollment.

Errors​

Self-service and challenge endpoints answer errors as:

{ "error": "invalid_code", "message": "That code didn't work. Check it and try again, or request a new one.", "attempts_remaining": 3 }
errorStatusMeaning
authenticator_not_found404 (410 for an expired setup)Unknown id, not yours, or no factor meets the requested tier (required_tier is included)
factor_not_allowed403 (400 for a passkey that is not FIDO-certified)The organization does not allow this factor type, or requires FIDO-certified authenticators and this passkey is not one
step_up_required403A fresh verification is needed; header: "X-MFA-Challenge" is included. See Step-up.
last_factor_required409The organization requires MFA and this is the user's last factor (or would leave fewer than the required number)
invalid_code400 (410 once the challenge is exhausted)Wrong code or credential; attempts_remaining on challenges; failed_attempts and hint: "check_time_sync" (after 3 failures) on TOTP enrollment
challenge_expired410The challenge or setup expired or was already used
challenge_binding_mismatch403The challenge belongs to a different session or token
mfa_locked42910 failures in 15 minutes; retry_after seconds
rate_limited429Too many pushes (3 per 5 minutes; retry_after), text messages, or challenge creates/answers (the per-user mfa_challenge budget shared with the hosted page, 10 per 15 minutes by default)
push_suspended429Push paused after 2 denials in 10 minutes; retry_after seconds
invalid_input422 (409 for recovery codes without a factor, or too many push devices)Invalid phone number, name too long, missing reason
delivery_failed503The text message could not be sent
not_found404Unknown challenge or trusted device

All 429 responses also carry a Retry-After header (seconds) matching retry_after in the body.

Admin endpoints use the admin envelope {"error", "message", "status", "details"} (see Errors).


Self-service endpoints​

List my authenticators​

curl https://app.lumoauth.dev/orgs/acme-corp/api/v1/me/authenticators \
-H "Authorization: Bearer $ACCESS_TOKEN"
{
"status": "add_backup",
"default": "totp:12",
"authenticators": [ { "id": "totp:12", "type": "totp", "display_name": "Authenticator app", "state": "active", "is_default": true, "tier": 3, "tier_label": "Standard", "…": "…" } ],
"recovery_codes": { "remaining": 10, "generated_at": "2026-09-24T10:01:00+00:00" },
"allowed_types": ["passkey", "push", "totp", "sms_otp"],
"recommended_type": "passkey"
}
  • status — protected (at least two, counting a recovery-code set as one), add_backup (one) or at_risk (none).
  • authenticators includes pending enrollments and excludes recovery codes (see recovery_codes).
  • allowed_types — factor types the organization allows and can deliver, in recommendation order.
  • recommended_type — the factor to offer first for this device (based on the request's User-Agent). A browser without a platform authenticator should skip passkey and offer the next allowed type.

Enroll an authenticator​

POST /orgs/{orgId}/api/v1/me/authenticators/{type}/enroll

type is one of totp, sms_otp, email_otp, push, passkey. The call creates a pending authenticator (discarded after 15 minutes unless verified) and returns 201 with what the client needs to finish. A user's first factor can be added within 15 minutes of signing in; every later one needs step-up.

totp — no body:

{
"id": "totp:12",
"state": "pending",
"otpauth_uri": "otpauth://totp/Acme%20Corp:ada%40acme.com?secret=JBSW…&issuer=Acme%20Corp&algorithm=SHA1&digits=6&period=30",
"secret": "JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP",
"secret_grouped": "JBSW Y3DP EHPK 3PXP JBSW Y3DP EHPK 3PXP",
"qr_data_uri": "data:image/png;base64,…",
"expires_at": "2026-09-24T10:15:00+00:00"
}

sms_otp — {"phone": "+14155551234", "country": "US"} (country is the default region for numbers without +). The number is validated and a code is texted:

{ "id": "sms_otp:13", "state": "pending", "masked_phone": "+1••••••1234", "resend_after": 30 }

Invalid, landline, VoIP-blocked or country-blocked numbers return 422 invalid_input with a user-facing message; see SMS & email.

email_otp — no body; a code is emailed to the account address:

{ "id": "email_otp:14", "state": "pending", "masked_email": "a••••@acme.com" }

push — no body:

{
"id": "push_enrollment:Qm9vdHN0cmFw…",
"state": "pending",
"qr_data_uri": "data:image/png;base64,…",
"deep_link": "lumoauth://enroll?d=eyJhY3Rpb24iOiJlbnJvbGwi…",
"activation_code": "ABCD1234",
"expires_at": "2026-09-24T10:10:00+00:00"
}

Show the QR code on desktop and the deep_link on phones; the activation code is for typing into the app. The enrollment expires after 10 minutes.

passkey — no body. public_key is the WebAuthn PublicKeyCredentialCreationOptions (discoverable credential, user verification required, existing passkeys excluded) to pass to navigator.credentials.create():

{ "id": "passkey:pending", "state": "pending", "public_key": { "rp": { "…": "…" }, "user": { "…": "…" }, "challenge": "…", "…": "…" } }

Verify a pending authenticator​

POST /orgs/{orgId}/api/v1/me/authenticators/{id}/verify
Pending idBodyResult
totp:12, sms_otp:13, email_otp:14{"code": "123456"}200 with the now-active authenticator
passkey:pending{"credential": { …PublicKeyCredential JSON… }}200 with the new passkey, named after the device and browser
push_enrollment:<token>{}200 with the enrollment state (poll until active)

The first active factor becomes the default, and a stronger factor replaces a weaker default. A wrong code returns 400 invalid_code.

Push enrollment states:

{ "status": "awaiting_test", "authenticator_id": "push:5", "number_match": 42 }
statusMeaning
pendingThe app has not connected yet
awaiting_testThe app enrolled; a test push was sent. Show number_match so the user can tap it in the app.
test_denied / test_expiredThe test push was denied or expired; the device stays pending. The hosted page can send it again.
activeDone — includes authenticator_id and authenticator
expired / cancelledThe enrollment token expired (10 minutes) or was cancelled
failedThe device was removed during enrollment

Rename or make default​

curl -X PATCH https://app.lumoauth.dev/orgs/acme-corp/api/v1/me/authenticators/passkey:7 \
-H "Authorization: Bearer $ACCESS_TOKEN" -H "Content-Type: application/json" \
-d '{"display_name": "Work laptop", "is_default": true}'

Returns the updated authenticator. Names are 1–64 characters (422 invalid_input otherwise). Recovery codes cannot be the default.

Remove an authenticator​

DELETE /orgs/{orgId}/api/v1/me/authenticators/{id}
X-MFA-Challenge: <approved challenge id>

Returns 204. Requires step-up with a different factor where the user has one (403 step_up_required), and is refused with 409 last_factor_required when the organization requires MFA and removing it would leave the user with fewer factors than required. Removing a factor revokes all of the user's trusted devices and emails the user.

Recovery codes​

curl https://app.lumoauth.dev/orgs/acme-corp/api/v1/me/recovery-codes \
-H "Authorization: Bearer $ACCESS_TOKEN"
{ "remaining": 2, "generated_at": "2026-09-24T10:01:00+00:00", "low": true }

remaining is null when the user never generated codes; low is true at 2 or fewer.

curl -X POST https://app.lumoauth.dev/orgs/acme-corp/api/v1/me/recovery-codes \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "X-MFA-Challenge: 0192a3f4-…"
{
"codes": ["ABCDE-FGHJK", "MNPQR-STUVW", "XYZ23-45678", "…"],
"remaining": 10,
"generated_at": "2026-09-24T12:00:00+00:00"
}

Returns 201 with 10 codes, shown once; the previous set stops working. Replacing an existing set needs step-up; generating the first set does not. A user without any factor gets 409.

Trusted devices​

curl https://app.lumoauth.dev/orgs/acme-corp/api/v1/me/trusted-devices \
-H "Authorization: Bearer $ACCESS_TOKEN"
{
"data": [
{
"id": "0192a3f4-5b6c-7d8e-9f00-112233445566",
"label": "Chrome on macOS",
"ip_address": "203.0.113.4",
"created_at": "2026-09-24T10:02:00+00:00",
"expires_at": "2026-10-24T10:02:00+00:00",
"last_used_at": "2026-09-30T08:15:00+00:00"
}
]
}

DELETE /me/trusted-devices/{id} revokes one (204, or 404 not_found). That browser is challenged at its next sign-in.


Challenges​

A challenge is one request to prove possession of an authenticator. Custom sign-in pages use it to finish a pending sign-in, and applications use it for step-up.

Start a challenge​

curl -X POST https://app.lumoauth.dev/orgs/acme-corp/api/v1/mfa/challenges \
-H "Authorization: Bearer $ACCESS_TOKEN" -H "Content-Type: application/json" \
-d '{"purpose": "step_up", "action_description": "Approve transfer of $500 to •••• 1122"}'
FieldDefaultDescription
purposestep_upstep_up or login
authenticator_idthe defaultChallenge a specific factor, e.g. "sms_otp:13" or "recovery_code:15"
min_tierpolicyRequire a factor at least this strong (1 = passkey only). Without it, step-up uses the policy's min_tier_step_up and login uses min_tier_login.
action_description—Shown in the LumoAuth app for push step-up (≤ 255 characters)

LumoAuth picks the user's default factor if it is strong enough, otherwise the strongest one that is, and delivers it immediately: sends the push, texts or emails the code, or prepares WebAuthn request options. Starting a new challenge expires the previous open one for the same session or token. Response 201:

{
"id": "0192a3f4-5b6c-7d8e-9f00-aabbccddeeff",
"purpose": "step_up",
"status": "issued",
"authenticator": { "id": "push:5", "type": "push", "display_name": "Ada's iPhone" },
"alternatives": [
{ "id": "totp:12", "type": "totp", "display_name": "Authenticator app", "tier": 3 },
{ "id": "recovery_code:15", "type": "recovery_code", "display_name": "Recovery codes", "tier": 5 }
],
"expires_at": "2026-09-24T12:01:00+00:00",
"attempts_remaining": 5,
"delivery": "push_sent",
"number_match": 42
}
FieldDescription
statusissued, approved, denied, expired or failed
alternativesOther factors for Try another way that are strong enough for this challenge, strongest first, plus recovery codes when the user has some left
deliverypush_sent (show number_match), sms_sent, email_sent, code_expected (TOTP or recovery code) or webauthn (use public_key with navigator.credentials.get())
expires_at60 seconds for push, 5 minutes otherwise

Errors: 404 authenticator_not_found (no factor meets the tier), 429 mfa_locked, 429 rate_limited (3 pushes per 5 minutes, or the challenge budget below), 429 push_suspended, 503 delivery_failed.

Creating and answering challenges spend one per-user budget shared with the hosted challenge page (10 per 15 minutes), counted before the challenge is looked up. Over it, both calls return 429 rate_limited with retry_after and a Retry-After header, so a custom UI cannot try codes faster than the hosted page allows.

Poll a challenge​

GET /orgs/{orgId}/api/v1/mfa/challenges/{id}

Poll this for push challenges until status becomes approved, denied or expired.

Answer a challenge​

curl -X POST https://app.lumoauth.dev/orgs/acme-corp/api/v1/mfa/challenges/0192a3f4-…/verify \
-H "Authorization: Bearer $ACCESS_TOKEN" -H "Content-Type: application/json" \
-d '{"code": "123456"}'
FactorBody
totp, sms_otp, email_otp, recovery_code{"code": "…"} (recovery codes with or without the hyphen, any case)
push{"code": "…"} with the LumoAuth app's offline code, when the phone cannot receive the push
passkey{"credential": { …PublicKeyCredential JSON from navigator.credentials.get()… }}

Returns 200 with the challenge (status: "approved"). A wrong answer returns 400 invalid_code with attempts_remaining; the fifth wrong answer exhausts the challenge (410). Ten failures in 15 minutes across all factors lock MFA for 15 minutes (429 mfa_locked with retry_after) and email the user.

Challenge binding​

Each challenge is bound to what created it: the browser session, or the exact access token (a hash of the Authorization value). Polling or answering it from another session or with another token returns 403 challenge_binding_mismatch. Use the same token for create, poll and verify.

Finishing a hosted sign-in from a custom UI​

When your own sign-in form runs on the LumoAuth domain and JSON login returns mfa_required, the browser session may call POST /mfa/challenges with "purpose": "login", then poll or verify it. Once the challenge is approved the session's second factor is satisfied and you can continue to /authorize. Tokens then carry amr and acr for the factor used.

The same rules as the hosted challenge page apply:

  • Each approval completes a session once. Polling or verifying an already-credited challenge again does not refresh step-up or issue another trusted-device cookie; start a new challenge.
  • Risky sign-ins need a strong factor. When adaptive authentication asked for a tier-1 or tier-2 factor, a weaker approved factor returns 403 with {"error": "insufficient_tier", "required_tier": 2}.
  • Recovery codes start a recovery. Answering with a recovery code leaves the session enrollment-only: other sessions and trusted devices are revoked and the next page load goes to Secure your account.

Step-up for API callers​

Sensitive self-service operations need a fresh verification:

OperationRule
POST /me/authenticators/{type}/enrollOnly when the user already has a factor
DELETE /me/authenticators/{id}With a different factor where the user has one
POST /me/recovery-codesWhen replacing an existing set
POST /admin/users/{userId}/temporary-access-codeFor admins calling with their own user token

Browser sessions are fresh when the session passed MFA within the policy's step_up_max_age_seconds (default 600) with a factor at or above min_tier_step_up. API callers send the id of an approved challenge of the same user, completed within that window, in the X-MFA-Challenge header:

# 1. Start a step-up challenge (default factor is delivered)
CH=$(curl -s -X POST https://app.lumoauth.dev/orgs/acme-corp/api/v1/mfa/challenges \
-H "Authorization: Bearer $ACCESS_TOKEN" -H "Content-Type: application/json" \
-d '{"purpose":"step_up"}' | jq -r .id)

# 2. Answer it
curl -s -X POST https://app.lumoauth.dev/orgs/acme-corp/api/v1/mfa/challenges/$CH/verify \
-H "Authorization: Bearer $ACCESS_TOKEN" -H "Content-Type: application/json" \
-d '{"code":"123456"}'

# 3. Call the sensitive endpoint with the approved challenge id
curl -X DELETE https://app.lumoauth.dev/orgs/acme-corp/api/v1/me/authenticators/sms_otp:13 \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "X-MFA-Challenge: $CH"

Without a valid header the endpoint returns:

{ "error": "step_up_required", "message": "Verify with a different sign-in method first.", "header": "X-MFA-Challenge" }

Only a challenge started with "purpose": "step_up" counts, and only when it was answered with a factor at or above the policy's min_tier_step_up (default 3, authenticator app or stronger); an approved login challenge is refused. For removal, the approving challenge must have used a different authenticator than the one being removed, unless it is the user's only factor. min_tier on POST /mfa/challenges can only ask for a stronger factor than the policy; a weaker value is clamped to the policy.

Users without a second factor. When the user has no usable factor there is nothing to step up with, so a browser session must instead have signed in (entered the password or used a passkey) within the last 10 minutes for the sensitive account actions: changing the password, removing a factor, downloading a personal data export and issuing a temporary access code. Older sessions are sent to sign in again. For OAuth-based step-up in the browser, request an acr_values level instead; see ACR and AMR.


Admin endpoints​

List a user's authenticators​

curl https://app.lumoauth.dev/orgs/acme-corp/api/v1/admin/users/01JF3KABCDE.../authenticators \
-H "X-API-Key: lmk_abc123"
{
"data": [ { "id": "push:5", "type": "push", "display_name": "Ada's iPhone", "state": "active", "tier": 2, "…": "…" } ],
"status": "protected",
"default": "push:5"
}

userId may be the user id or email. Includes pending enrollments and recovery codes (with remaining); never secrets.

Remove a user's authenticator​

curl -X DELETE https://app.lumoauth.dev/orgs/acme-corp/api/v1/admin/users/01JF3KABCDE.../authenticators/push:5 \
-H "X-API-Key: lmk_abc123"
{ "data": { "id": "push:5", "type": "push", "…": "…" }, "message": "Authenticator removed" }

Revokes one authenticator (for example a lost phone), revokes the user's trusted devices and notifies the user. It does not bypass the policy: if MFA is required, the user is asked to enroll again at their next sign-in. Audited as mfa.factor.removed with actor type admin.

Issue a temporary access code​

curl -X POST https://app.lumoauth.dev/orgs/acme-corp/api/v1/admin/users/01JF3KABCDE.../temporary-access-code \
-H "X-API-Key: lmk_abc123" -H "Content-Type: application/json" \
-d '{"reason": "Lost phone; identity verified by video call", "ttl_minutes": 60, "single_use": true}'
FieldDefaultDescription
reason— (required)Stored in the audit log (422 when missing)
ttl_minutes60Clamped to 5–10080 (7 days)
single_usetruefalse lets the code be used until it expires

Response 201 — the code is returned once:

{
"data": {
"id": "0192a3f4-…",
"code": "ABCD-EFGH-JKMN",
"expires_at": "2026-09-24T11:00:00+00:00",
"single_use": true
}
}

The user enters the code on the MFA challenge page (I have a temporary access code from my admin). It satisfies the second factor for that sign-in only to let the user enroll new factors; their other sessions and trusted devices are revoked. Issuing a new code revokes the previous one. Audited as mfa.tap.issued and emailed to the user.

An admin calling with their own user token must have passed MFA within the step-up window; otherwise:

{
"error": "step_up_required",
"message": "Confirm your own identity with MFA before issuing an access code.",
"status": 403,
"details": { "step_up": { "header": "X-MFA-Challenge", "challenge_endpoint": "/orgs/acme-corp/api/v1/mfa/challenges" } }
}

MFA policy​

curl https://app.lumoauth.dev/orgs/acme-corp/api/v1/admin/policies/mfa \
-H "X-API-Key: lmk_abc123"
{
"data": {
"requirement": "risk_based",
"allowed_factors": ["passkey", "push", "totp", "sms_otp"],
"min_tier_login": 4,
"min_tier_step_up": 3,
"passwordless_passkey": true,
"required_factor_count": 1,
"grace_period_days": 14,
"remind_interval_days": 7,
"trusted_device_days": 30,
"email_otp_counts_as_mfa": false,
"sms_block_voip": false,
"sms_country_allowlist": [],
"sms_country_denylist": [],
"nudge_interval_days": 30,
"step_up_max_age_seconds": 600,
"required_since": null
},
"effective_allowed_factors": ["passkey", "push", "totp"]
}

effective_allowed_factors drops factors the organization cannot deliver (no SMS provider, push not in the plan).

PUT accepts partial bodies:

curl -X PUT https://app.lumoauth.dev/orgs/acme-corp/api/v1/admin/policies/mfa \
-H "X-API-Key: lmk_abc123" -H "Content-Type: application/json" \
-d '{"requirement": "required", "grace_period_days": 7, "min_tier_step_up": 2}'

Returns {"data": { …full policy… }}. Unknown keys are ignored, numbers are clamped to their ranges (see the policy reference), and an invalid requirement returns 422. required_since is read-only: it is set when the requirement switches to required (starting existing users' grace period) and cleared when it switches away. The legacy authentication settings mfa_required and mfa_sms_enabled are updated to match. Changes apply at each user's next sign-in and are audited as mfa.policy.changed with a from/to diff.

Coverage report​

curl https://app.lumoauth.dev/orgs/acme-corp/api/v1/admin/reports/mfa-coverage \
-H "X-API-Key: lmk_abc123"
{
"data": {
"total_users": 1200,
"enrolled_users": 930,
"enrolled_pct": 77.5,
"by_type": { "totp": 410, "sms_otp": 120, "email_otp": 0, "passkey": 520, "push": 260, "recovery_code": 700 },
"by_tier": { "1": 520, "2": 180, "3": 170, "4": 60 },
"requirement": "required",
"in_grace": 200,
"past_grace": 70,
"deferred": 150,
"generated_at": "2026-09-24T12:00:00+00:00"
}
}

Active users only. by_type counts users holding each type; by_tier counts users by their strongest factor; in_grace / past_grace are only non-zero when the requirement is required; deferred counts unenrolled users who chose "Remind me later".

Reset MFA (removed)​

POST /orgs/{orgId}/api/v1/admin/users/{userId}/mfa/reset

Admins can no longer switch MFA off for a user. The endpoint remains so callers get a clear answer:

{
"error": "mfa_reset_removed",
"message": "Admins cannot disable MFA for a user. Issue a temporary access code instead.",
"status": 410,
"details": { "use": "/orgs/acme-corp/api/v1/admin/users/01JF3KABCDE.../temporary-access-code" }
}

Use temporary access codes for locked-out users and Remove a user's authenticator for a lost device.

Standards used on this page​

Plain nameStandard
Step-up authentication challengeRFC 9470
Authentication method reference (amr) valuesRFC 8176
Passkeys (WebAuthn and FIDO2)W3C Web Authentication and FIDO Alliance CTAP

See the standards reference for what each one covers and how LumoAuth uses it.