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
| Endpoints | Credential |
|---|---|
/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:
| Endpoints | Scope |
|---|---|
GET /me/authenticators, GET /me/recovery-codes, GET /me/trusted-devices | mfa:read or mfa:manage |
Every other /me/authenticators*, /me/recovery-codes, /me/trusted-devices/{id} call | mfa: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.
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.
| Method | Endpoint | Permission | API-key / token scope |
|---|---|---|---|
| GET | /admin/users/{userId}/authenticators | users.view | admin:users:read |
| DELETE | /admin/users/{userId}/authenticators/{authenticatorId} | users.edit | admin:users:write |
| POST | /admin/users/{userId}/temporary-access-code | users.edit | admin:users:write |
| GET | /admin/policies/mfa | settings.view | admin:settings:read |
| PUT | /admin/policies/mfa | settings.manage | admin:settings:write |
| GET | /admin/reports/mfa-coverage | users.view | admin:users:read |
Authenticator ids
Authenticators are addressed by a reference {type}:{id}:
| Type | Example | Notes |
|---|---|---|
passkey | passkey:7 | Tier 1 |
push | push:5 | Tier 2 — a LumoAuth app device |
totp | totp:12 | Tier 3 |
sms_otp | sms_otp:13 | Tier 4 |
email_otp | email_otp:14 | Tier 4; counts as a second factor only when the policy allows it |
recovery_code | recovery_code:15 | Recovery 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}
}
| Field | Description |
|---|---|
state | pending (enrollment not verified yet), active, suspended |
tier / tier_label | 1 Strongest, 2 Strong, 3 Standard, 4 Basic, 5 Recovery |
amr | Authentication 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_context | IP, coarse location and user agent of the last successful use, or null |
metadata | Per 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 }
error | Status | Meaning |
|---|---|---|
authenticator_not_found | 404 (410 for an expired setup) | Unknown id, not yours, or no factor meets the requested tier (required_tier is included) |
factor_not_allowed | 403 (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_required | 403 | A fresh verification is needed; header: "X-MFA-Challenge" is included. See Step-up. |
last_factor_required | 409 | The organization requires MFA and this is the user's last factor (or would leave fewer than the required number) |
invalid_code | 400 (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_expired | 410 | The challenge or setup expired or was already used |
challenge_binding_mismatch | 403 | The challenge belongs to a different session or token |
mfa_locked | 429 | 10 failures in 15 minutes; retry_after seconds |
rate_limited | 429 | Too 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_suspended | 429 | Push paused after 2 denials in 10 minutes; retry_after seconds |
invalid_input | 422 (409 for recovery codes without a factor, or too many push devices) | Invalid phone number, name too long, missing reason |
delivery_failed | 503 | The text message could not be sent |
not_found | 404 | Unknown 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
- Node
- Python
curl https://app.lumoauth.dev/orgs/acme-corp/api/v1/me/authenticators \
-H "Authorization: Bearer $ACCESS_TOKEN"
const resp = await fetch("https://app.lumoauth.dev/orgs/acme-corp/api/v1/me/authenticators", {
headers: { "Authorization": `Bearer ${accessToken}` }
});
console.log(await resp.json());
import requests
resp = requests.get(
"https://app.lumoauth.dev/orgs/acme-corp/api/v1/me/authenticators",
headers={"Authorization": f"Bearer {access_token}"},
)
print(resp.json())
{
"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) orat_risk(none).authenticatorsincludes pending enrollments and excludes recovery codes (seerecovery_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'sUser-Agent). A browser without a platform authenticator should skippasskeyand 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 id | Body | Result |
|---|---|---|
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 }
status | Meaning |
|---|---|
pending | The app has not connected yet |
awaiting_test | The app enrolled; a test push was sent. Show number_match so the user can tap it in the app. |
test_denied / test_expired | The test push was denied or expired; the device stays pending. The hosted page can send it again. |
active | Done — includes authenticator_id and authenticator |
expired / cancelled | The enrollment token expired (10 minutes) or was cancelled |
failed | The 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"}'
| Field | Default | Description |
|---|---|---|
purpose | step_up | step_up or login |
authenticator_id | the default | Challenge a specific factor, e.g. "sms_otp:13" or "recovery_code:15" |
min_tier | policy | Require 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
}
| Field | Description |
|---|---|
status | issued, approved, denied, expired or failed |
alternatives | Other factors for Try another way that are strong enough for this challenge, strongest first, plus recovery codes when the user has some left |
delivery | push_sent (show number_match), sms_sent, email_sent, code_expected (TOTP or recovery code) or webauthn (use public_key with navigator.credentials.get()) |
expires_at | 60 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"}'
| Factor | Body |
|---|---|
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
403with{"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:
| Operation | Rule |
|---|---|
POST /me/authenticators/{type}/enroll | Only when the user already has a factor |
DELETE /me/authenticators/{id} | With a different factor where the user has one |
POST /me/recovery-codes | When replacing an existing set |
POST /admin/users/{userId}/temporary-access-code | For 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:
- curl
- Node
# 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"
const base = "https://app.lumoauth.dev/orgs/acme-corp/api/v1";
const auth = { "Authorization": `Bearer ${accessToken}`, "Content-Type": "application/json" };
const ch = await (await fetch(`${base}/mfa/challenges`, {
method: "POST", headers: auth, body: JSON.stringify({ purpose: "step_up" })
})).json();
await fetch(`${base}/mfa/challenges/${ch.id}/verify`, {
method: "POST", headers: auth, body: JSON.stringify({ code: userEnteredCode })
});
const resp = await fetch(`${base}/me/authenticators/sms_otp:13`, {
method: "DELETE", headers: { ...auth, "X-MFA-Challenge": ch.id }
});
console.log(resp.status); // 204
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}'
| Field | Default | Description |
|---|---|---|
reason | — (required) | Stored in the audit log (422 when missing) |
ttl_minutes | 60 | Clamped to 5–10080 (7 days) |
single_use | true | false 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 name | Standard |
|---|---|
| Step-up authentication challenge | RFC 9470 |
Authentication method reference (amr) values | RFC 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.