Skip to main content

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.

Compatibility policy — additive only

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").

1.2 requires number matching

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: s MUST 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-use challenge_nonce; the JIT challenges (1.3) embed both — a nonce from /jit/challenge and iat.

Enrollment flow (recap)​

Direct enrollment is not supported (POST /enroll returns 410 Gone). Enrollment is always anchored to an authenticated user session:

  1. 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 Sign-in & security page of the end-user account with the Add a sign-in method menu open

    The server creates an enrollment session with an enrollment_token and a short activation code for manual entry. Both are single-use and expire after 10 minutes.

  2. The app obtains the enrollment_token — by scanning the QR code, by opening the deep link, or by POST /resolve-activation-code with {"activation_code": "..."}, which returns the enrollment_token, user email, organization name/slug and expiry.

  3. 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"
}
}
FieldTypeSinceDescription
activation_pendingbool1.2true: the device is pending until the user approves the enrollment test push. A pending device can only answer its own enroll_test request.
totpobject1.2Offline 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.

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)>
1.3 changes the JIT challenge format

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

FieldTypeSinceNullableDescription
typestring1.0noAlways jit_approval_request
request_idstring1.0noJIT request id (jit_...) — use with the /jit/* endpoints
account_idstring1.0noThe receiving device's account id
tenant_namestring1.0noOrganization display name
agent_namestring1.0noRequesting agent's display name
risk_levelstring1.0noRequest risk: low | medium | high | critical
expires_atstring (ISO 8601)1.0noWhen the approval window closes
timestampstring (ISO 8601)1.0noWhen the request was created
originstring1.1nojit | ciba — whether the request came from the JIT API or an agent CIBA initiation
summarystring1.1dropped under size pressureHumane server-built line: "<agent> wants to <actions> on <type> <identifier>", ≤ 200 chars
requested_ttlstring (int)1.1noRequested token TTL in seconds
agentstring (JSON object)1.1dropped 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​

FieldTypeSinceNullableDescription
typestring1.0noAlways auth_request
request_idstring1.0noRequest token — use with /request-details and /respond
account_idstring1.0noThe receiving device's account id
tenant_namestring1.0noOrganization display name
timestampstring (ISO 8601)1.0noCreation time
expires_atstring (ISO 8601)1.0noExpiry: 60 s for mfa, step_up and enroll_test; 120 s for passwordless login; up to 600 s for agent CIBA prompts
purposestring1.2nologin (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.
originstring1.1only on agent promptsciba | jit. Absent on plain login prompts
summarystring1.1only on agent promptsAction description / binding message (≤ 200 chars)
risk_levelstring1.1only on agent promptsAction impact tier: low | medium | high | critical
agentstring (JSON object)1.1only on agent promptsSame 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
}
FieldTypeSinceNullableDescription
purposestring1.2noSame values as the push payload purpose
number_match_requiredbool1.2notrue for every non-agent request
number_choicesarray of int1.2no (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_namestring1.2yesApplication the user is signing in to, when known
browser, osstring1.2yesParsed from the initiating user agent, e.g. Chrome / macOS
created_atstring (ISO 8601)1.2noRequest creation time
action_descriptionstring1.2yesFor step-up: the action being approved, e.g. "Approve transfer of $500 to •••• 1122". Show it prominently.
require_biometricbool1.2notrue 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:

FieldTypeSinceDescription
numberint1.2The number the user picked. Required to approve when number_match_required is true.
report_fraudbool1.2With 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:

CaseStatusBody
Approved200{"status": "approved"}
Denied200{"status": "denied"}
Approved with the wrong number200{"status": "denied", "reason": "number_mismatch"} — the request is denied and counts as a denial
Approve without number when required400{"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
}
FieldTypeSinceNullable
request_id, agent_id, agent_name, task_id, risk_level, statusstring1.0no
on_behalf_ofstring (email)1.0yes
requested_ttlint1.0no
created_at, expires_atstring (ISO 8601)1.0no
originstring (jit | ciba)1.1no
summarystring1.1no
on_behalf_of_namestring (display name)1.1yes
agentobject {agent_id, name, risk_level}1.1no
delegation_consent_requiredbool — the agent asserted on_behalf_of without a delegation token; the named user's consent (or a reviewer override) is what is pending1.3no

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:

FieldTypeSinceNullable
authorization_detailsobject (rich authorization request)1.0no
justificationstring1.0yes
risk_assessmentobject1.0yes
contextobject1.0yes
client_ip, user_agentstring1.0yes
risk_factorsarray of strings1.1no (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 familyWindowAnchor
request-details:…60 s (abs(now - iat))client iat
enroll:…, token-update:…, unenroll:…300 sclient 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)KeyDefault
/initiateorganization + email5 / 10 min
/resolve-activation-codeIP + code5 / hour
/complete-enrollmentIP + token3 / 15 min
/statusIP30 / 60 s
/request-detailsaccount10 / 60 s
/respond (and /jit/respond per request, /jit/respond-batch per device)request id / device10 / 5 min
/update-token, /unenrollIP + account10 / 10 min
/jit/challenge, /jit/list, /jit/details, /jit/respond* (shared device limiter)account30 / 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​

StatusMeaning on this surface
400Malformed 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
401Stale iat or failed signature on read endpoints (/request-details, /jit/* context), failed enrollment proof-of-possession
403Failed signature on mutating endpoints, missing jit.approve permission, consumed / expired / foreign challenge nonce (login and /jit/*)
404Unknown org, request, or device (also returned for cross-device access attempts — deliberately indistinguishable, and for a pending device answering anything but its enrollment test)
409Device limit reached; JIT decision conflict (e.g. self-approval)
410Deprecated /enroll; request/enrollment no longer pending
429Rate 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​

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 nameStandard
Rich authorization requestsRFC 9396

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