Login flow
Approve a sign-in with the SenseCrypt Mobile SDK — submit the sign-in link from a scanned QR or a pushed notification, handle an optional password step, capture a face, and post the result.
The login flow approves a sign-in that some relying party has initiated. Your app supplies three inputs — the sign-in link (the payload of a scanned QR, or the universal link a backchannel request pushed or emailed), optionally a password, and a face capture — and observes the state machine to drive its UI.
This page is for approving a sign-in that another application started. To sign a user in to your own app, with no relying party and no link, use In-app sign-in instead.
Underneath the link. The QR a browser shows and the link a CIBA request sends both carry the same SenseCrypt sign-in link. It binds the session to this device; the hardware-bound device key (ES256) that signs the posted result proves the device, and the live face match proves the person.
Starting the flow
let flow = authn.startLoginFlow()
Task {
for await state in flow.stateStream() {
handle(state)
}
}stateStream() yields the current state immediately, then every transition — so your collector sees the starting state without waiting.
val flow = withContext(Dispatchers.IO) { authn.startLoginFlow() }
lifecycleScope.launch {
flow.state.collect { state -> handle(state) }
}LoginFlow.state is a SharedFlow, not a StateFlow — it has no .value. Read the current state synchronously via the currentState property instead.
startLoginFlow() does not throw. Start it when the user is ready to scan, or when a link arrives, not at app launch.
The state machine
The flow runs the liveness check before the face match. A badly framed frame then fails on liveness and does not spend a face-mismatch attempt.
States and their payloads
| State | Payload | What your UI does |
|---|---|---|
idle | — | Nothing yet. |
awaitingLink | lastError: LinkError? | Show the camera, or wait for a pushed link. On a non-nil lastError, show the matching message before resuming. |
downloadingFaceToken | rpLabel: String?, passwordRequired: Bool, passwordIsNumeric: Bool, lastPasswordError: PasswordError? | Show a fetching interstitial. When passwordRequired flips true, switch to a password prompt. |
awaitingFaceCapture | rpLabel, lastError: FaceCaptureRetryReason?, branding: Branding, bindingMessage: String?, isCiba: Bool, expiresAtUnix: Int64?, attemptsUsed: UInt32, attemptsMax: UInt32 | Show the pre-scan screen, then the camera. |
runningLiveness | — | On-device work in progress. Stop submitting frames. |
matchingFace | — | As above. |
postingResult | — | Network POST in flight; show a blocking scrim. |
postingResultFailed | reason: FailureReason | Not terminal. Offer "Try again" → retryPostResult(). |
succeeded | rpLabel: String, timestamp: Int64 | Terminal success. |
cancelled | — | Terminal. |
failed | reason: FailureReason | Terminal. See Errors. |
Step 1 — submit the link payload
Pass the bytes straight through, whether they came from the QR scanner or from the universal link the OS handed your app. The call returns immediately; observe the state for the outcome.
flow.submitLinkPayload(payload: linkBytes)flow.submitLinkPayload(linkBytes)If the link is refused, the flow stays on awaitingLink with a LinkError:
LinkError | Meaning |
|---|---|
invalidLink | Not a SenseCrypt sign-in link, or malformed. |
expired | The session behind the link has already expired. |
unknownKey(recipientEmail, appLabel, appLogoBytes, appLogoMime, appPrimaryColor, appAccentColor) | The link targets an identity this device holds no key for. |
network | The lookup could not reach the portal. |
attestationFailed | Device attestation refused to issue a token, and the tenant enforces attestation. Terminal. |
accessDenied | The server's group access gate refused this identity/app pair. Terminal. |
pkiCeremony | The link opens a face-PKI ceremony, not a sign-in. Nothing was touched. |
For unknownKey you'd typically show an "Add key for appLabel?" modal — the case carries the relying party's logo bytes and colours so you can brand it. If the user dismisses without choosing, call resetToScan() to clear lastError and resume scanning rather than terminating the flow.
For pkiCeremony, start a PkiCeremonyFlow and submit the same bytes to it. A pushed link can be either kind and your app cannot tell them apart beforehand; each flow names the other's link. The SenseCrypt Authenticator submits every link to a LoginFlow first and hands a refused ceremony link across.
attestationFailed and accessDenied are terminal link-phase failures — re-aiming the camera cannot fix either. Don't call resetToScan() for them; route the user to a message instead. See Errors for the full semantics.
Step 2 — the optional password step
Some sessions require a password or PIN before the face step. Watch for downloadingFaceToken with passwordRequired == true, then prompt.
flow.submitPassword(password: entered)flow.submitPassword(entered)passwordIsNumeric tells you which keyboard to show — numeric keypad when true, full keyboard otherwise. It is only a keyboard hint and never gates validation, so don't use it to restrict input.
A wrong password re-emits downloadingFaceToken with lastPasswordError set to wrongPassword (or network). submitPassword is only valid while in this state.
Step 3 — capture a face
Face capture is a shared sub-flow. Ask the login flow for a driver, then let a capture surface drive it:
let driver = flow.faceCaptureDriver()
SenseCryptFaceScanView(driver: driver) { outcome in
// CaptureOutcome: .succeeded / .cancelled / .failed(reason:)
}val driver = flow.faceCaptureDriver()
// Observe driver.state (SharedFlow<FaceCaptureUiState>) and submit
// passing frames via driver.submitFaceCapture(handle).The same driver and the same capture surface serve login, registration, the face report and face-PKI ceremonies — the driver normalizes each flow's state onto a common FaceCaptureUiState, so you build the camera screen once. See UI components for the bundled surfaces and the FaceCaptureUiState contract.
On Android, submit captures and retries through the driver (submitFaceCapture, retry, cancel). iOS additionally exposes submitFaceCapture and retryPostResult on LoginFlow itself, but driving the driver is the portable path and the one to prefer in cross-platform code.
The retry budget
awaitingFaceCapture carries attemptsUsed and attemptsMax. Render them rather than hard-coding a limit — "Couldn't recognize you (2 of 3)".
Two rules that matter:
- Only face mismatches count. Liveness runs first and a liveness failure loops back for another try without consuming the budget. Check
lastErrorto tell which happened:faceMismatchorlivenessFailed. - Exhausting the budget is handled for you. The SDK signs an abort to the portal with
fail_reason="face_mismatch_max_retries"and transitions tofailed(reason: .faceMismatchMaxRetries). You don't need to count or abort yourself.
Session context for the pre-scan screen
Four payload fields on awaitingFaceCapture exist for the "what am I approving?" screen:
branding— the relying party's merged branding (org name, logo bytes, colours). Pre-fetched duringdownloadingFaceToken, so this state renders instantly. Cached per RP.bindingMessage— for CIBA sessions, the RP-supplied human-readable string, e.g. "Pay USD 10,000 to Acme Corp." Deliberately not part ofbranding, because branding is cached per RP on disk and would replay a stale message on a later sign-in.nilfor browser sessions and message-less CIBA requests.isCiba—truewhen the session was started by a backchannel request rather than a browser, so you can word the screen as an approval rather than a scan.expiresAtUnix— absolute session expiry as a Unix timestamp, for a live "Expires in M:SS" countdown. Display-only — the server enforces expiry regardless of what you render.
Step 4 — handle the post
After the face match passes, the SDK posts the result. Two outcomes need your attention:
postingResultFailed(reason:) is recoverable. The POST got no answer, a 5xx or a 429. Call retryPostResult() and the cached face proof is re-sent — the user does not re-scan the QR or re-capture their face. Any other refusal is the server's verdict on that request — the same bytes would get the same answer — so it ends in failed and is never offered a retry.
flow.retryPostResult()flow.retryPostResult()Gate your retry button on the state actually being postingResultFailed. Calling retryPostResult() from any other state finds no cached inputs and surfaces failed(reason: .internal).
failed(reason:) is terminal. Hard failures — signature rejected, token expired, access denied — do not pass through postingResultFailed. See the full reason table.
Cancelling
There are two cancel methods and the difference is externally visible:
| Method | Scope | Use when |
|---|---|---|
cancel() | Local only. No server notification. | Your screen is tearing down — backgrounded, killed, navigated away. No time or no auth to signal the portal. |
cancelLogin() | Notifies the portal. Signed POST to abort the session with fail_reason="user_cancelled", then terminates as failed(reason: .loginCancelledByUser). | The user taps an explicit Cancel button while still in the flow. |
cancelLogin() is idempotent, and a network failure on the abort is swallowed — the user reaches the terminal screen either way, and the session simply records as a timeout later instead of an instant cancellation. If no session has started yet (the user cancels on the scan screen), it falls back to a local-only cancel.
Prefer cancelLogin() for user-initiated cancellation: it makes the dashboard show user_cancelled immediately rather than waiting out the session timeout.
Displaying the session QR
loginQrPng() returns PNG bytes of the universal-link QR for this session, or nil before the face-token payload has been fetched. Read it on each state transition and refresh your cached bitmap.
if let png = flow.loginQrPng() {
// UIImage(data: png)
}flow.loginQrPng()?.let { png ->
// BitmapFactory.decodeByteArray(png, 0, png.size)
}Related
- Registration — enrolling a user before they can sign in.
- Face-PKI ceremony — the other flow a pushed link can open.
- Running as the tenant's app — claiming the host the links arrive on.
- UI components — bundled QR and face-capture surfaces.
- Errors — every failure reason and its recovery.
- CIBA — backchannel sessions and binding messages.
Running as the tenant's app
What a host app built on the SenseCrypt Mobile SDK must declare when it is the app a tenant's users install — app links on the tenant's app-link host, the custom URL scheme, attestation identity, and push through the tenant's own Firebase project.
In-app sign-in (first-party)
Sign a user in to your own mobile app with a face scan inside the app, with no browser, and get IdP-issued DPoP-bound tokens. The SignInFlow, the console opt-in it needs, how to call your APIs with the tokens, and how the session is refreshed.