SenseCrypt Docs
SDKs & appsMobile SDK

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

submitLinkPayload refused link / resetToScan submitPassword (wrong) resetToScan submitFaceCapture liveness failed (retry) face mismatch (retry) transient retryPostResult cancelLogin cancel hard error Idle AwaitingLink DownloadingFaceToken AwaitingFaceCapture RunningLiveness MatchingFace PostingResult Succeeded PostingResultFailed Failed Cancelled

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

StatePayloadWhat your UI does
idle—Nothing yet.
awaitingLinklastError: LinkError?Show the camera, or wait for a pushed link. On a non-nil lastError, show the matching message before resuming.
downloadingFaceTokenrpLabel: String?, passwordRequired: Bool, passwordIsNumeric: Bool, lastPasswordError: PasswordError?Show a fetching interstitial. When passwordRequired flips true, switch to a password prompt.
awaitingFaceCapturerpLabel, lastError: FaceCaptureRetryReason?, branding: Branding, bindingMessage: String?, isCiba: Bool, expiresAtUnix: Int64?, attemptsUsed: UInt32, attemptsMax: UInt32Show 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.
postingResultFailedreason: FailureReasonNot terminal. Offer "Try again" → retryPostResult().
succeededrpLabel: String, timestamp: Int64Terminal success.
cancelled—Terminal.
failedreason: FailureReasonTerminal. See Errors.

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:

LinkErrorMeaning
invalidLinkNot a SenseCrypt sign-in link, or malformed.
expiredThe 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.
networkThe lookup could not reach the portal.
attestationFailedDevice attestation refused to issue a token, and the tenant enforces attestation. Terminal.
accessDeniedThe server's group access gate refused this identity/app pair. Terminal.
pkiCeremonyThe 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 lastError to tell which happened: faceMismatch or livenessFailed.
  • Exhausting the budget is handled for you. The SDK signs an abort to the portal with fail_reason="face_mismatch_max_retries" and transitions to failed(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 during downloadingFaceToken, 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 of branding, because branding is cached per RP on disk and would replay a stale message on a later sign-in. nil for browser sessions and message-less CIBA requests.
  • isCiba — true when 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:

MethodScopeUse 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)
}

On this page