SenseCrypt Docs
SDKs & appsMobile SDK

Registration flow

Enroll a user and mint a device key with the SenseCrypt Mobile SDK — email PIN verification, the self-signup branch with attribute collection and consent, and face capture.

Registration proves control of an email address, then binds a device key, generated in the phone's secure hardware, to the identity. It has two branches that share a start:

  • Existing identity — the user is already known to the directory. Verify the emailed PIN and a device key is minted. No face capture in this branch.
  • Self-signup — the identity is new and the application allows signup. After the PIN, the SDK collects profile attributes and a consent acknowledgement, then captures a face to mint a fresh face token.

Which branch you land in is decided by the server, not by your app. Observe the state stream and render whatever it asks for.

Starting the flow

let flow = try authn.startRegistrationFlow(
    email: "alice@example.com",
    clientId: "your-client-id",
    sessionId: nil
)

Task {
    for await state in flow.stateStream() {
        handle(state)
    }
}
val flow = withContext(Dispatchers.IO) {
    authn.startRegistrationFlow(
        email = "alice@example.com",
        clientId = "your-client-id",
        sessionId = null,
    )
}

lifecycleScope.launch {
    flow.state.collect { state -> handle(state) }
}
ParameterMeaning
emailThe address to enroll. The server emails a PIN to it.
clientIdYour application's client identifier.
sessionIdThe OAuth session id from a QR that initiated this registration — the "add a key" path, where a sign-in QR was scanned on a device with no key. Pass nil for registration your app initiated on its own.

sessionId matters for the self-signup branch: when present, the SDK sends it on completion so the server finishes the OAuth session in the same call, and the user is not asked for a second face scan.

This method throws AuthnInitError (Internal) when the flow cannot be constructed — see Errors.

The state machine

no answer, after Failed no answer on resend, after Failed submitPin resendPin / wrong PIN existing identity another phone would be signed out confirmDeviceReplacement cancel 409 device_replacement_required signup required submitSignupAttributes rejected submitFaceCapture liveness failed systemPasskeyMode (Android 9–13) submitSystemPasskeyResponse / declineSystemPasskey transient retryPostResult hard error Idle SubmittingEmail AwaitingPin VerifyingPin GeneratingDeviceKey AwaitingDeviceReplacementConfirm Cancelled CompletingRegistration CollectingAttributes AwaitingFaceCapture MintingFaceToken AwaitingSystemPasskey Succeeded PostingResultFailed Failed

States and their payloads

StatePayloadWhat your UI does
idle—Nothing yet.
submittingEmail—Show a spinner.
awaitingPinemail, attemptsLeft: UInt32, cooldownUntil: Int64?, pinExpiresInSeconds: UInt32?, codeDispatched: BoolShow the PIN entry screen.
verifyingPin—Spinner.
collectingAttributesemail, attributes: [SignupAttribute], termsUrl: String?, privacyUrl: String?, rejected: SignupAttributeRejection?, priorValues: [String: String], priorAddress: [String: String]?Render the signup form.
awaitingFaceCapturelastError: FaceCaptureRetryReason?Open the camera.
mintingFaceToken—On-device work; spinner.
generatingDeviceKey—Spinner.
awaitingSystemPasskeyrequestJson: StringAndroid 9–13 only, with systemPasskeyMode. Run Credential Manager createCredential() with the request, then call submitSystemPasskeyResponse() or declineSystemPasskey().
completingRegistration—Spinner.
postingResultFailedreason: FailureReasonNot terminal. On either branch — the signup /signup/complete or the device-add /register/complete. Only a lost answer, a 5xx or a 429 lands here; any other refusal is failed. A lost answer is first re-sent once by the SDK itself and lands here only when the re-send gets no answer either. Offer "Try again" → retryPostResult().
succeededkey: KeyTerminal. The minted device key.
cancelled—Terminal.
awaitingDeviceReplacementConfirmotherDevices: [OtherDevice]Not terminal. The tenant allows one device per user and completing this enrollment would sign the user's other phone(s) out. Show them (see One device per user), then call confirmDeviceReplacement() or cancel().
failedreason: FailureReasonTerminal, with two exceptions. In system-passkey mode a malformed registrationResponseJson emits failed(.internal) and then re-enters awaitingSystemPasskey, which still accepts a submit or a decline. And a /register/start (or CIBA start) that got no answer emits failed(.networkUnreachable) and then re-enters the state it left — idle, or awaitingPin with codeDispatched: false — so resendPin() on the same flow asks again; a bound CIBA link is probed as CIBA again, never demoted to a PIN.

Step 1 — the email PIN

flow.submitPin(pin: entered)
flow.resendPin()
flow.submitPin(entered)
flow.resendPin()

The awaitingPin payload gives you everything the screen needs:

  • attemptsLeft — remaining tries. Exhausting them terminates with failed(reason: .pinAttemptsExhausted).
  • pinExpiresInSeconds — for a countdown. Expiry terminates with failed(reason: .pinExpired).
  • cooldownUntil — Unix timestamp until which resendPin() is rate-limited. Disable the resend button until then.
  • codeDispatched — whether an email was actually sent on this transition.

Honour codeDispatched. It is false when the server suppressed the email — a PIN for this address was already requested inside the resend cooldown window, so the valid code is the one already in the inbox. It is also false on a wrong-PIN re-prompt. Showing "We sent you a code" unconditionally tells the user to look for an email that was never sent.

Step 2 — the self-signup branch

If the server flags signup, you get collectingAttributes with the directory's enabled attribute schema. Render one typed input per entry, in ordering order.

Each SignupAttribute carries:

FieldTypeMeaning
keyStringIdentifier to key your submitted value by.
dataTypeAttributeDataTypeWhich input control to render.
labelStringDisplay label.
requiredBoolWhether it must be filled.
orderingInt32Form order.
options[String]Allowed values for enumerated types.
constraintsAttributeConstraints?Additional validation constraints.

name is included in the schema; email is not — it is already known.

termsUrl and privacyUrl are the effective Terms-of-Use and Privacy-Policy URLs, resolved server-side from a per-app override or the deployment default.

When these are non-nil, you must render an "I agree" checkbox linking both, and must not let the user advance until it is ticked. Pass the result as consentAccepted. The SDK echoes the URLs and the flag back to the server for the audit record.

nil means the server predates this feature; skip the consent gate in that case.

Submitting the form

flow.submitSignupAttributes(
    values: ["name": "Alice Smith", "phone_number": "+15551234567"],
    address: ["street_address": "1 Main St", "locality": "Springfield"],
    consentAccepted: true
)
flow.submitSignupAttributes(
    mapOf("name" to "Alice Smith", "phone_number" to "+15551234567"),
    mapOf("street_address" to "1 Main St", "locality" to "Springfield"),
    true,
)

values is keyed by attribute key, with scalars as strings. address carries the structured OIDC address sub-fields separately when that attribute was filled; pass nil if not. The SDK extracts name for its dedicated wire field and forwards the rest.

Handling rejection

The server validates values the device can't check locally — most commonly a coupon code. On rejection the flow re-enters collectingAttributes with:

  • rejected — a SignupAttributeRejection. Its key names the offending field, or is nil for a form-level error such as a network blip; message is the server's reason. Show it inline and re-enable the form.
  • priorValues / priorAddress — what the user already typed. Re-populate the form from these so nothing has to be retyped. Both are empty/nil on first entry.

Step 3 — face capture (self-signup only)

Same driver pattern as login:

let driver = flow.faceCaptureDriver()

SenseCryptFaceScanView(driver: driver) { outcome in
    // CaptureOutcome
}
val driver = flow.faceCaptureDriver()
// Observe driver.state and submit via driver.submitFaceCapture(handle).

Signup has no face-mismatch retries. There is no existing token to match against — the SDK is minting a fresh one — so the only retry reason here is livenessFailed. faceMismatch never occurs in this branch, and the corresponding attemptsMax on the capture state is 0, meaning no mismatch budget is enforced.

Step 4 — completion

On success you get succeeded(key:) carrying the minted Key. The device can now approve sign-ins.

postingResultFailed(reason:) works exactly as in login: not terminal, call retryPostResult() to re-send the cached completion — the signup proof, or on the device-add branch the /register/complete request. No face re-capture, no device-key re-mint, no second PIN. The one field a retry refreshes is the Android Play Integrity token the completion carries for enrolment-time attestation, minted again over the same challenge because the server accepts a token for five minutes. Only a lost answer, a 5xx or a 429 parks here; a refusal is the server's verdict on that request and ends in failed. A lost answer (no answer, a reset, a read timeout) is re-sent once by the SDK itself first, after a short pause — the server re-serves the same completion with its original response and writes nothing — so postingResultFailed after a lost answer means two lost answers in a row; a 5xx or a 429 parks here at once. Gate the retry CTA on observing that state.

flow.retryPostResult()
// Signup branch: retry through the capture driver...
driver.retry()
// ...or, on either branch, directly on the flow.
flow.retryPostResult()

On the signup branch the FaceCaptureDriver's retry() re-fires the same cached POST; the device-add branch has no capture, so Android's RegistrationFlow wrapper exposes retryPostResult() as well.

One device per user

A tenant may allow each user one device (the console's Issuer page, or one_device_per_user on PATCH /v1/admin/issuer). Under such a tenant, enrolling a further phone signs the user's other phones out, and the user must agree to that on the new phone first.

The SDK handles the server side of it. On /register/verify it names the phone it is enrolling, and when the answer lists the user's other phone(s) the flow pauses in awaitingDeviceReplacementConfirm(otherDevices:) before the device key is minted, so nothing has been written yet. Each OtherDevice carries deviceName, os, osVersion (any of them nil for a phone enrolled before the server recorded them) and lastUsedAt (RFC 3339). Show the user which device will go and ask:

case let .awaitingDeviceReplacementConfirm(otherDevices):
    // "Adding this device signs out <name>, last used <when>. Continue?"
    if userAgreed {
        flow.confirmDeviceReplacement()
    } else {
        flow.cancel()
    }
is RegistrationState.AwaitingDeviceReplacementConfirm -> {
    // "Adding this device signs out <name>, last used <when>. Continue?"
    if (userAgreed) flow.confirmDeviceReplacement() else flow.cancel()
}

confirmDeviceReplacement() sends the completion with the user's confirmation; the server revokes their keys on every other phone in the same transaction as the new key, and the flow continues to succeeded. cancel() leaves every phone as it was; the registration token lapses unspent. The same state can also arrive from the completion itself, when the server could not see the other phone at verify time (the setting was turned on, or another phone was enrolled, in between): the SDK holds the exact request it sent and re-sends it on confirmation.

A reinstalled phone counts as a further device: the SDK mints a fresh device id after it wipes its last key, so the user's own earlier copy is listed and they are asked to sign it out. Confirming is harmless; that copy no longer holds a key.

The other phone learns on its next request, sync or rotation that its key is gone because the account moved: the SDK reports deviceKeyReplaced rather than deviceKeyRevoked, RevokedKeyNotice.replaced on a rotation, and BrandingSyncSummary.replacedKeys on a sync. Say so, and do not offer to add the account there again by default: that would sign the new phone out in turn.

Android 9–13: the system-passkey branch

Android 9 to 13 cannot host a third-party passkey provider, so on those versions the passkey is kept in Google Password Manager. Start the flow with systemPasskeyMode = true (Kotlin only) and the SDK mints no local passkey. Instead, after the device key is generated, it emits awaitingSystemPasskey(requestJson); run an in-app Credential Manager createCredential() with that request and answer with submitSystemPasskeyResponse(registrationResponseJson), or declineSystemPasskey() if the user backs out. The rest of the flow is unchanged. Sign-in on those devices then runs the face report through startPendingFaceReportFlow(); see Passkeys and the face report.

Registration-specific failure reasons

Beyond the common reasons, these appear mainly here:

ReasonMeaning
pinAttemptsExhaustedToo many wrong PINs.
pinExpiredThe PIN aged out before submission.
emailAlreadyExistsSignup attempted for an address that already has an identity.
signupNotAllowedThe application does not permit self-signup. Direct the user to an admin.
accountSuspendedThe address belongs to a suspended user, said once the PIN matched (or at the CIBA start). Terminal: offer neither a retry nor self-signup. Reinstating the user lifts it.
livenessFailedLiveness could not be established.

Other enrollment paths

Registration through the SDK is one of three ways a user gets enrolled. In a tenant with Face PKI, every path also seals the user's post-quantum keys into the face token it mints; your app does nothing extra. The other two paths happen outside your app:

  • Admin provisioning — an operator creates the user in the console, optionally from a photo.
  • Directory provisioning (SCIM) — your IdP creates a pending shell that the user claims on first use. See SCIM.

In every path, control of the email address is proven before a device key is bound.

  • Login — approving sign-ins once enrolled.
  • Key management — rotating and revoking device keys.
  • Errors — the complete failure surface.
  • Reference — Key, SignupAttribute, and the enums.

On this page