Mobile approvals device API
This page is the versioned wire contract between LumoAuth and the mobile
authenticator app (or any custom app implementing the same protocol). It covers
device enrollment, the FCM push payload schema, and every device-signed
endpoint under /orgs/{orgId}/push-auth/* — including the JIT agent-approval
endpoints that power push approval,
JIT permissions (just-in-time: short-lived access an agent asks
for and a human approves), and
async authorization (agent CIBA) (the agent asks
a user to approve on their own device) on a phone.
Current contract version: 1.3 (October 2026). Version 1.0 is everything that existed before the fields marked 1.1, 1.2 and 1.3 below.
Contract revisions only add keys. Existing keys are never renamed, removed, or changed in type within a major version. Clients MUST ignore unknown keys in push payloads and JSON responses. New keys are marked with the version that introduced them ("since").
Version 1.2 is additive on the wire, but the server now requires the new
number field to approve any sign-in, step-up or enrollment-test request
(see /respond). A 1.0/1.1 client can still deny
requests and handle agent approvals, but its sign-in approvals are refused with
400 number_match_required — ship a 1.2 client before users need to approve
sign-ins. Enrollment also changed: a new device stays pending until it approves
a test push.
Device signing contract
Every authenticated device call proves possession of the device's enrolled key:
- Key: ECDSA P-256 (enrolled as a JWK with
kty=EC,crv=P-256). - The device signs the UTF-8 bytes of the challenge string with ECDSA-P256-SHA256 as a single operation (the crypto library performs the SHA-256 internally — do not pre-hash).
- Signature encoding: base64url(R || S) — exactly 64 raw bytes (two 32-byte big-endian integers). DER signatures are rejected.
- Low-S required:
sMUST be ≤ n/2 (P-256 group order). Non-canonical signatures are rejected (malleability guard). - Most challenges embed a client-supplied unix timestamp
iat, sent alongside the signature in the request body and validated against a freshness window (see Freshness windows). Login approve/deny challenges instead embed a server-issued, single-usechallenge_nonce; the JIT challenges (1.3) embed both — a nonce from/jit/challengeandiat.
Enrollment flow (recap)
Direct enrollment is not supported (POST /enroll returns 410 Gone).
Enrollment is always anchored to an authenticated user session:
-
The user starts enrollment from Secure your account or from Sign-in & security in their account (Add a sign-in method → LumoAuth app), or through an admin flow.

The server creates an enrollment session with an
enrollment_tokenand a short activation code for manual entry. Both are single-use and expire after 10 minutes. -
The app obtains the
enrollment_token— by scanning the QR code, by opening the deep link, or byPOST /resolve-activation-codewith{"activation_code": "..."}, which returns theenrollment_token, user email, organization name/slug and expiry. -
The app generates a P-256 key pair in the device keystore and calls
POST /complete-enrollment:
{
"enrollment_token": "enr_...",
"public_key": "{\"kty\":\"EC\",\"crv\":\"P-256\",\"x\":\"...\",\"y\":\"...\"}",
"fcm_token": "fcm-registration-token",
"device_name": "Ada's iPhone",
"platform": "ios",
"iat": 1767222900,
"enrollment_signature": "base64url(R||S)"
}
The enrollment_signature is proof-of-possession over the challenge
enroll:{enrollment_token}:{iat}, signed with the key being enrolled.
Success returns 201 with the durable device identity:
{
"account_id": "42",
"device_id": "3f0c9a...",
"enrolled": true,
"email": "ada@acme.com",
"tenant_name": "Acme Corporation",
"activation_pending": true,
"totp": {
"secret": "JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP",
"algorithm": "SHA1",
"digits": 6,
"period": 30,
"issuer": "Acme Corporation",
"account": "ada@acme.com"
}
}
| Field | Type | Since | Description |
|---|---|---|---|
activation_pending | bool | 1.2 | true: the device is pending until the user approves the enrollment test push. A pending device can only answer its own enroll_test request. |
totp | object | 1.2 | Offline code seed. The app stores it and shows 6-digit codes the user can type into Use a code from the app instead when the phone is offline. Returned once; only the encrypted seed is kept server-side. |
Right after enrollment the server sends a test push (purpose: "enroll_test", number-matched like a sign-in). The web page that started the
enrollment shows the number and continues automatically once the app approves
it; on a denial or expiry the user can send the test again. The device becomes
active only after the test is approved.
account_id is the identifier the device presents on every subsequent call.
Enrollment tokens are single-use (atomically claimed — a concurrent second
completion gets 410). A user can hold at most 5 active devices; other
enrolled devices receive an informational device_roster_change push when a
device is added or removed. FCM registration tokens are stored encrypted at
rest.
QR code and deep link
The QR code encodes this JSON (no user email, for privacy):
{"action":"enroll","server":"https://app.lumoauth.dev","tenant":"acme-corp","token":"<enrollment_token>","tenant_name":"Acme Corporation"}
On a phone the page offers Open the LumoAuth app instead, a deep link carrying the same JSON, base64url-encoded without padding:
lumoauth://enroll?d=<base64url(QR JSON)>
Version 1.3 adds no new response shapes, but every device-signed /jit/*
call now needs a server-issued, single-use challenge_nonce obtained from
POST /jit/challenge and embedded in the signed
challenge (<seed>:{challenge_nonce}:{iat}). A 1.2 client's JIT calls are
refused with 400 (no nonce) — ship a 1.3 client before approvers need to
review agent requests from the phone. Sign-in approvals are unchanged.
Push payload schema
Pushes are delivered as FCM messages with a data map. All data values are
strings (FCM requirement) — the agent field is a JSON object serialized as
a string. Payloads carry no end-user PII (no emails, IPs, or locations);
the app fetches those from the device-signed detail endpoints after proving
key possession.
type: "jit_approval_request" — JIT / CIBA approval prompt
Sent to every enrolled device of users holding jit.approve in the
organization (the delegation target's own devices are excluded — self-approval
is disallowed).
| Field | Type | Since | Nullable | Description |
|---|---|---|---|---|
type | string | 1.0 | no | Always jit_approval_request |
request_id | string | 1.0 | no | JIT request id (jit_...) — use with the /jit/* endpoints |
account_id | string | 1.0 | no | The receiving device's account id |
tenant_name | string | 1.0 | no | Organization display name |
agent_name | string | 1.0 | no | Requesting agent's display name |
risk_level | string | 1.0 | no | Request risk: low | medium | high | critical |
expires_at | string (ISO 8601) | 1.0 | no | When the approval window closes |
timestamp | string (ISO 8601) | 1.0 | no | When the request was created |
origin | string | 1.1 | no | jit | ciba — whether the request came from the JIT API or an agent CIBA initiation |
summary | string | 1.1 | dropped under size pressure | Humane server-built line: "<agent> wants to <actions> on <type> <identifier>", ≤ 200 chars |
requested_ttl | string (int) | 1.1 | no | Requested token TTL in seconds |
agent | string (JSON object) | 1.1 | dropped under size pressure | {"agent_id": "agt_...", "name": "...", "risk_level": "low|medium|high"} — risk_level here is the agent's governance posture, not the per-request risk |
Deliberately not in the push payload (fetch via /jit/details):
on_behalf_of, risk_factors, authorization_details, justification.
type: "auth_request" — sign-in / step-up / agent-approval prompt
| Field | Type | Since | Nullable | Description |
|---|---|---|---|---|
type | string | 1.0 | no | Always auth_request |
request_id | string | 1.0 | no | Request token — use with /request-details and /respond |
account_id | string | 1.0 | no | The receiving device's account id |
tenant_name | string | 1.0 | no | Organization display name |
timestamp | string (ISO 8601) | 1.0 | no | Creation time |
expires_at | string (ISO 8601) | 1.0 | no | Expiry: 60 s for mfa, step_up and enroll_test; 120 s for passwordless login; up to 600 s for agent CIBA prompts |
purpose | string | 1.2 | no | login (passwordless sign-in), mfa (second factor), step_up (sensitive action), enroll_test (enrollment test push) or agent (agent approval). Lets the app pick the right screen before fetching details. The match number is not in the payload. |
origin | string | 1.1 | only on agent prompts | ciba | jit. Absent on plain login prompts |
summary | string | 1.1 | only on agent prompts | Action description / binding message (≤ 200 chars) |
risk_level | string | 1.1 | only on agent prompts | Action impact tier: low | medium | high | critical |
agent | string (JSON object) | 1.1 | only on agent prompts | Same shape as above |
type: "device_roster_change" — informational
account_id, message, timestamp (all 1.0). No response expected.
Payload size guarantee
FCM caps messages at 4096 bytes. The server enforces a 3072-byte budget on
the JSON-encoded data map: when exceeded, summary is truncated (with an
ellipsis) first, then agent is dropped, then summary is dropped. Identifier
fields (request_id, account_id, …) are never truncated, so a client can
always fetch full details.
Device endpoints
All endpoints are POST with a JSON body under /orgs/{orgId}/push-auth
(where {orgId} is the organization slug), except GET /discover. Responses
are JSON; errors are {"error": "<message>"} with the status codes listed in
Error codes.
GET /discover
Public. Returns {"tenant_name", "tenant_slug", "push_auth_enabled"} — used
by the app to validate a scanned org before enrollment.
POST /resolve-activation-code
Public, heavily rate-limited. Body {"activation_code": "ABCD1234"} →
{"enrollment_token", "email", "tenant_name", "tenant_slug", "expires_at"}.
Invalid or expired codes return 400; repeated failures expire the pending
enrollment session.
POST /complete-enrollment
See Enrollment flow.
Challenge: enroll:{enrollment_token}:{iat} (signed with the key being
enrolled).
POST /request-details
Fetch full request details (email, IP, location, user agent, number
choices) after a push. Body: request_id, account_id, iat, signature.
Challenge: request-details:{request_id}:{account_id}:{iat} — 60 s
freshness window. The response includes the challenge_nonce required to
respond:
{
"request_id": "…", "account_id": "42", "tenant_name": "Acme Corporation",
"email": "ada@acme.com", "ip_address": "203.0.113.7", "location": "Berlin, DE",
"user_agent": "Mozilla/5.0 …", "timestamp": "…", "expires_at": "…",
"status": "pending", "challenge_nonce": "9f2c…",
"purpose": "mfa",
"number_match_required": true,
"number_choices": [17, 42, 88],
"app_name": null,
"browser": "Chrome",
"os": "macOS",
"created_at": "2026-09-24T10:00:00+00:00",
"action_description": null,
"require_biometric": true
}
| Field | Type | Since | Nullable | Description |
|---|---|---|---|---|
purpose | string | 1.2 | no | Same values as the push payload purpose |
number_match_required | bool | 1.2 | no | true for every non-agent request |
number_choices | array of int | 1.2 | no (empty when not required) | Three distinct two-digit numbers, one of them correct, in random order. Show them as the picker; the user taps the one on the sign-in screen. |
app_name | string | 1.2 | yes | Application the user is signing in to, when known |
browser, os | string | 1.2 | yes | Parsed from the initiating user agent, e.g. Chrome / macOS |
created_at | string (ISO 8601) | 1.2 | no | Request creation time |
action_description | string | 1.2 | yes | For step-up: the action being approved, e.g. "Approve transfer of $500 to •••• 1122". Show it prominently. |
require_biometric | bool | 1.2 | no | true for sign-in, step-up and enrollment prompts: unlock with biometrics or device passcode before signing |
POST /respond
Approve or deny a sign-in / step-up / enrollment-test / agent-approval
prompt. Body: request_id, account_id, action (approve | deny),
signature, and:
| Field | Type | Since | Description |
|---|---|---|---|
number | int | 1.2 | The number the user picked. Required to approve when number_match_required is true. |
report_fraud | bool | 1.2 | With action: "deny": the user chose Deny – this wasn't me. Ignored for agent approvals. |
Challenge:
- approve, number-matched:
auth-request:{request_id}:{challenge_nonce}:{number} - approve, agent approval (no number):
auth-request:{request_id}:{challenge_nonce} - deny (with or without
report_fraud):deny-request:{request_id}:{challenge_nonce}
The challenge_nonce is server-issued (via /request-details), single-use,
and consumed on success — a captured signature cannot be replayed. Denying
requires a signature too.
Responses:
| Case | Status | Body |
|---|---|---|
| Approved | 200 | {"status": "approved"} |
| Denied | 200 | {"status": "denied"} |
| Approved with the wrong number | 200 | {"status": "denied", "reason": "number_mismatch"} — the request is denied and counts as a denial |
Approve without number when required | 400 | {"error": "number_match_required", "message": "Update the app to approve sign-in requests."} |
A denial with report_fraud: true is audited as mfa.push.reported_fraud,
flags the account and emails the user. Every denial of a non-agent request
counts toward the per-user push suspension (2 denials in 10 minutes suspend
push for 30 minutes). When the prompt is an agent-approval (Ask-to-Approve /
CIBA), the decision also resolves the linked agent request.
POST /initiate
Public. Starts a passwordless push sign-in for {"email": "..."} (used by the
hosted login page). Response:
{"request_sent": true, "expires_in": 120, "session_nonce": "…", "number_match": 42}
number_match (since 1.2) is the number the sign-in page must display. The
response shape is identical for unknown emails, users without a device, and
users over their per-user push budget or with push suspended (a random number,
no push sent), so it reveals nothing about the account.
POST /update-token
Rotate the FCM registration token. Body: account_id, fcm_token, iat,
signature. Challenge:
token-update:{account_id}:{iat}:{sha256_hex(fcm_token)} — the token
digest is bound into the signature so a replayed signature cannot install an
attacker-chosen FCM token. Response {"updated": true}.
POST /unenroll
Revoke this device. Body: account_id, iat, signature. Challenge:
unenroll:{account_id}:{iat}. Response {"unenrolled": true}.
POST /jit/challenge
Since 1.3. Mint a single-use server nonce for the next device-signed /jit/*
call. Body: account_id. No signature is needed (the nonce is useless without
the device key); issuance shares the per-account JIT rate limit.
{ "challenge_nonce": "9f1c0e5a7b3d4c2e8a1f6b9d0c3e5a7f", "expires_in": 300 }
The nonce is bound to the device, expires after 300 s, and is consumed
by the first successfully verified signature — fetch a fresh one before
every /jit/list, /jit/details, /jit/respond and /jit/respond-batch
call. A reused, expired or foreign nonce fails with 403 and
"code": "challenge_nonce_invalid"; a missing or malformed one with 400 and
"code": "challenge_nonce_missing". Clients should branch on code, not on the
message text, and retry once with a fresh nonce on challenge_nonce_invalid.
Because every signed JIT call now costs two requests (challenge + call), the
per-account JIT device limiter default is 60 per minute.
POST /jit/list
List pending JIT approval requests the signer may review. The device's bound
user must hold the jit.approve permission; requests where the signer is
the delegation target (on_behalf_of = signer) are excluded. Body:
account_id, challenge_nonce, iat, signature. Challenge:
jit-list:{challenge_nonce}:{iat}.
{
"pending_requests": [
{
"request_id": "jit_9a41…",
"agent_id": "agt_billing_bot",
"agent_name": "Billing Bot",
"task_id": "task_ab12…",
"risk_level": "high",
"on_behalf_of": "ada@acme.com",
"requested_ttl": 300,
"created_at": "2026-07-01T14:00:00+00:00",
"expires_at": "2026-07-01T14:05:00+00:00",
"status": "pending",
"origin": "jit",
"summary": "Billing Bot wants to read and transfer on payment_initiation acct_42",
"on_behalf_of_name": "Ada Lovelace",
"agent": { "agent_id": "agt_billing_bot", "name": "Billing Bot", "risk_level": "low" },
"delegation_consent_required": false
}
],
"count": 1
}
| Field | Type | Since | Nullable |
|---|---|---|---|
request_id, agent_id, agent_name, task_id, risk_level, status | string | 1.0 | no |
on_behalf_of | string (email) | 1.0 | yes |
requested_ttl | int | 1.0 | no |
created_at, expires_at | string (ISO 8601) | 1.0 | no |
origin | string (jit | ciba) | 1.1 | no |
summary | string | 1.1 | no |
on_behalf_of_name | string (display name) | 1.1 | yes |
agent | object {agent_id, name, risk_level} | 1.1 | no |
delegation_consent_required | bool — the agent asserted on_behalf_of without a delegation token; the named user's consent (or a reviewer override) is what is pending | 1.3 | no |
POST /jit/details
Full detail for one request. Body: jit_request_id, account_id,
challenge_nonce, iat, signature. Challenge:
jit-details:{jit_request_id}:{challenge_nonce}:{iat}.
Returns every /jit/list field plus:
| Field | Type | Since | Nullable |
|---|---|---|---|
authorization_details | object (rich authorization request) | 1.0 | no |
justification | string | 1.0 | yes |
risk_assessment | object | 1.0 | yes |
context | object | 1.0 | yes |
client_ip, user_agent | string | 1.0 | yes |
risk_factors | array of strings | 1.1 | no (may be empty) |
risk_factors is the flat factor list from the risk engine (e.g.
critical_resource_type, high_risk_action:transfer, delegation_context,
agent_posture:high) so clients don't have to parse the risk_assessment
blob.
POST /jit/respond
Approve or deny one JIT request. Body: jit_request_id, action
(approve | deny), optional notes (≤ 500 chars; internal, never shown to
the agent), account_id, challenge_nonce, iat, signature. Challenge:
- approve:
jit-approve:{jit_request_id}:{challenge_nonce}:{iat} - deny:
jit-deny:{jit_request_id}:{challenge_nonce}:{iat}
Response: {"status": "approved"|"denied", "request_id": "jit_…"}. A request
that was resolved concurrently returns 410; a self-approval or permission
failure returns 409/403.
POST /jit/respond-batch
One signature covers up to 25 requests. Body: jit_request_ids (array),
action, optional notes, account_id, challenge_nonce, iat,
signature. Challenge — ids sorted lexicographically and comma-joined:
- approve:
jit-approve-batch:{id1,id2,...}:{challenge_nonce}:{iat} - deny:
jit-deny-batch:{id1,id2,...}:{challenge_nonce}:{iat}
Per-id results with partial success (see JIT permissions for the full request/response example):
{ "action": "approve", "results": { "jit_a…": {"status": "approved"}, "jit_b…": {"status": "error", "error": "not_pending"} }, "succeeded": 1, "failed": 1 }
Per-id error values: not_found, not_pending, conflict, forbidden.
Freshness windows
| Challenge family | Window | Anchor |
|---|---|---|
request-details:… | 60 s (abs(now - iat)) | client iat |
enroll:…, token-update:…, unenroll:… | 300 s | client iat |
jit-list:…, jit-details:…, jit-approve/deny:…, jit-*-batch:… | 300 s (abs(now - iat)) and a server challenge_nonce (300 s, single-use) | client iat + server nonce |
auth-request:… / deny-request:… | until request expiry (60 s for second factor, step-up and enrollment test; 120 s for passwordless login) | server challenge_nonce, single-use |
Rate limits
The limits, fixed per deployment:
| Endpoint(s) | Key | Default |
|---|---|---|
/initiate | organization + email | 5 / 10 min |
/resolve-activation-code | IP + code | 5 / hour |
/complete-enrollment | IP + token | 3 / 15 min |
/status | IP | 30 / 60 s |
/request-details | account | 10 / 60 s |
/respond (and /jit/respond per request, /jit/respond-batch per device) | request id / device | 10 / 5 min |
/update-token, /unenroll | IP + account | 10 / 10 min |
/jit/challenge, /jit/list, /jit/details, /jit/respond* (shared device limiter) | account | 30 / 60 s |
Exceeding a limit returns 429 with {"error": "Too many requests…"}.
Independently of these endpoint limits, LumoAuth limits pushes per user for passwordless sign-in, second-factor and step-up requests: at most 3 per 5 minutes, and 2 denials within 10 minutes suspend push for that user for 30 minutes. While limited or suspended no push is sent; the sign-in page asks the user to use another factor.
Error codes
| Status | Meaning on this surface |
|---|---|
400 | Malformed JSON, missing required fields (including a missing or malformed challenge_nonce on /jit/*), invalid action/platform, stale iat on lifecycle endpoints; number_match_required when approving without number |
401 | Stale iat or failed signature on read endpoints (/request-details, /jit/* context), failed enrollment proof-of-possession |
403 | Failed signature on mutating endpoints, missing jit.approve permission, consumed / expired / foreign challenge nonce (login and /jit/*) |
404 | Unknown org, request, or device (also returned for cross-device access attempts — deliberately indistinguishable, and for a pending device answering anything but its enrollment test) |
409 | Device limit reached; JIT decision conflict (e.g. self-approval) |
410 | Deprecated /enroll; request/enrollment no longer pending |
429 | Rate limit exceeded |
Error bodies are {"error": "<human-readable message>"} — match on status
codes, not message text. The one exception is number_match_required, a
stable machine code (with a message) so old clients can prompt users to
update.
See also
- Push approval for agent actions — the agent-side API that creates these prompts
- JIT permissions — the HITL + token model behind
/jit/* - Async authorization (agent CIBA) — CIBA-origin prompts
Normative references. This contract relies on the standards below; the table maps the plain names used above to the specifications.
Standards used on this page
| Plain name | Standard |
|---|---|
| Rich authorization requests | RFC 9396 |
See the standards reference for what each one covers and how LumoAuth uses it.