SenseCrypt Docs
SDKs & appsMobile SDK

Login flow

Approve a sign-in with the SenseCrypt Mobile SDK — scan the QR, 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 — a scanned QR payload, optionally a password, and a face capture — and observes the state machine to drive its UI.

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, not at app launch.

The state machine

submitQrPayload invalid QR / resetToScan submitPassword (wrong) resetToScan submitFaceCapture face mismatch (retry) liveness failed (retry) transient retryPostResult cancelLogin hard error Idle AwaitingQrScan DownloadingFaceToken AwaitingFaceCapture MatchingFace RunningLiveness PostingResult Succeeded PostingResultFailed Cancelled Failed

States and their payloads

StatePayloadWhat your UI does
idleNothing yet.
awaitingQrScanlastError: QrScanError?Show the camera. 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?, expiresAtUnix: Int64?, attemptsUsed: UInt32, attemptsMax: UInt32Show the pre-scan screen, then the camera.
matchingFaceOn-device work in progress. Stop submitting frames.
runningLivenessAs above.
postingResultNetwork POST in flight; show a blocking scrim.
postingResultFailedreason: FailureReasonNot terminal. Offer "Try again" → retryPostResult().
succeededrpLabel: String, timestamp: Int64Terminal success.
cancelledTerminal.
failedreason: FailureReasonTerminal. See Errors.

Step 1 — submit the QR payload

Pass the decoded bytes straight through. The call returns immediately; observe the state for the outcome.

flow.submitQrPayload(payload: decodedBytes)
flow.submitQrPayload(decodedBytes)

If the payload is rejected, the flow returns to awaitingQrScan with a QrScanError:

QrScanErrorMeaning
invalidQrNot a SenseCrypt sign-in QR, or malformed.
expiredThe session behind the QR has already expired.
unknownKey(recipientEmail, appLabel, appLogoBytes, appLogoMime, appPrimaryColor, appAccentColor)The QR targets an identity this device holds no key for.
networkThe lookup could not reach the portal.
attestationFailedDevice attestation refused to issue a token. Terminal.
accessDeniedThe server's group access gate refused this identity/app pair. Terminal.

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.

attestationFailed and accessDenied are terminal QR-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 both login and registration — 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 failures loop 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

Three 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.
  • 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 liveness passes, the SDK posts the result. Two outcomes need your attention:

postingResultFailed(reason:) is recoverable. The POST hit a transient network or portal error. Call retryPostResult() and the cached face proof is re-sent — the user does not re-scan the QR or re-capture their face.

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 QR 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)
}
  • Registration — enrolling a user before they can sign in.
  • UI components — bundled QR and face-capture surfaces.
  • Errors — every failure reason and its recovery.
  • CIBA — backchannel sessions and binding messages.

On this page