Passkeys and the face report
Act as the phone's passkey provider with the SenseCrypt Mobile SDK — publish identities, sign the assertion, then prove the person with the face-report flow; plus the Android system-passkey and cross-device paths.
On the passkey login method a sign-in has two halves. The phone's credential provider signs a WebAuthn assertion and returns it to the browser, which SenseCrypt verifies and parks. Then the app proves the person: it fetches the face token for that exact ceremony, matches a live face, and files a signed face report. SenseCrypt pairs the two and the sign-in completes. The SDK gives you both halves. Your app owns what only an app can own: the provider extension or service, its registration, and its screens.
Assertion first, face second. Return the credential to the platform before your app comes forward. On iOS a credential-provider extension runs under a memory cap the face pipeline does not fit, and bringing the app forward while the ceremony is live cancels it. The server enforces the same order everywhere: it releases a face template, and accepts a report, only for an assertion it has already verified and parked for this identity.
Publishing identities
The OS offers a passkey only if it knows the device holds one.
iOS matches against a store the app fills ahead of time. Call this after enrolment, on every launch, and after anything that changes the set (revocation, rotation, a restore from backup):
let published = await authn.publishPasskeyIdentities()It replaces the whole store rather than adding to it, so a revoked key stops being offered. clearPublishedPasskeyIdentities() drops everything when the last key goes away. Publishing only works while the app is enabled as a credential provider: check SenseCryptCredentialProvider.isEnabled (an async getter), and ask with SenseCryptCredentialProvider.promptToEnable(), which returns whether the provider is now on and shows the system prompt in place on iOS 18 and later and falls back to Settings on older versions. There is no programmatic enable, by design.
Android asks the provider live. Your CredentialProviderService answers onBeginGetCredentialRequest from the SDK:
val identities = authn.passkeyIdentitiesFor(rpId)passkeyIdentities() returns every passkey the device holds; passkeyIdentitiesFor(rpId) filters to one relying party. CredentialProviderStatus tells you whether the platform supports third-party providers (isSupported(), Android 14 and later), what state yours is in (state(context, serviceClass), one of ENABLED, DISABLED or UNKNOWN; isEnabled(context, serviceClass) is the definite yes), and gives you the Settings intent to send the user to (settingsIntent(context), null below Android 14). Gate an enable prompt on DISABLED, not on isEnabled being false: some OEM builds cannot answer, and an UNKNOWN device must not be nagged.
Each PasskeyIdentity carries credentialIdB64url, rpId, userName and userHandleB64url. The relying-party ID is the tenant's sign-in host, echoed by the server at enrolment.
Signing the assertion
In the provider, sign over the client-data hash the OS hands you. The call is synchronous and offline, and does no face work. It throws SignAssertionError when there is nothing usable to sign with:
let assertion = try authn.signAssertion(
rpId: request.credentialIdentity.relyingPartyIdentifier,
clientDataHash: request.clientDataHash,
credentialID: request.credentialIdentity.credentialID
)
// return assertion.asPasskeyAssertionCredential(relyingParty:clientDataHash:) to the OS
// (nil when a field fails to decode: treat that as a failed ceremony)val assertion = authn.signAssertion(
rpId = rpId,
clientDataHashB64url = clientDataHashB64url,
credentialIdB64url = credentialIdB64url,
)
// return assertion.toPublicKeyCredentialJson() to Credential ManagerPass the credential ID the OS says the user chose. An rpId names the tenant, so every account enrolled in it answers the same request; the credential ID decides which account signs. nil works only while the device holds exactly one credential for the relying party; otherwise the SDK refuses rather than guess. Carry the same value to the face report.
The result is a WebAuthnAssertion with credentialIdB64url, authenticatorDataB64url, signatureB64url and userHandleB64url. Return it to the platform, then record rpId, the client-data hash and the credential ID somewhere the containing app can read.
The face-report flow
In the app, start the second half with the values the provider recorded:
let flow = authn.startFaceReportFlow(
rpId: rpId,
clientDataHashB64url: hash,
credentialIdB64url: credentialId
)
Task {
for await state in flow.stateStream() {
handle(state)
}
}val flow = authn.startFaceReportFlow(rpId, clientDataHashB64url, credentialIdB64url)
lifecycleScope.launch {
flow.state.collect { state -> handle(state) }
}The server resolves the hash to the parked assertion, so the pre-scan screen is branded and, for a CIBA request, carries the binding message.
| State | Payload | What your UI does |
|---|---|---|
starting | — | Spinner. |
downloadingFaceToken | recipient: String?, passwordRequired, passwordIsNumeric, lastPasswordError: PasswordError? | Fetching interstitial; a password prompt when passwordRequired is true. Same step as in login. |
awaitingFaceCapture | recipient, lastError: FaceCaptureRetryReason?, branding, bindingMessage: String?, isCiba, expiresAtUnix, attemptsUsed, attemptsMax | Show the pre-scan screen, then the camera. |
matchingFace | — | On-device work. Stop submitting frames. |
postingResult | — | The signed report is in flight. |
postingResultFailed | reason: FailureReason | Not terminal. Offer "Try again" → retryPostResult(). |
succeeded | — | Terminal. The browser completes the sign-in. |
cancelled | — | Terminal. |
failed | reason: FailureReason | Terminal. |
The flow exposes submitPassword, submitFaceCapture, faceCaptureDriver(), retryPostResult(), cancel() and currentState. The capture driver is the same one login uses, so your camera screen serves both. cancel() tears the flow down locally and, once the ceremony has been resolved, files a best-effort user_cancelled report to the WebAuthn abort endpoint so the waiting page can end at once. An exhausted retry budget files a terminal face_mismatch_max_retries report and ends in failed. There is no session id on this path; the report is keyed by the client-data hash.
Three failure reasons are specific to this flow:
| Reason | Meaning | Recoverable |
|---|---|---|
ceremonyNotReady | The browser half has not been parked yet. | Yes. Ask again; the assertion stays live until the pairing window closes. |
ceremonyFailed(reason) | The server says the ceremony is over, and why: wrong_account, passkey_revoked, user_abandoned, ceremony_expired, and others. | No. Map the reasons you have copy for and fall back to generic copy; the vocabulary can grow without an app update. |
noCredentialForRelyingParty | The OS invoked you for a relying party this device holds no passkey for, or one whose device key has since been severed. | No. Re-publish identities and steer the user to enrol. |
Android 9 to 13: system passkeys
Those Android versions cannot host a third-party passkey provider, so the passkey lives in Google Password Manager instead.
Enrolment. Start registration with systemPasskeyMode = true. Instead of minting a local passkey, the flow emits awaitingSystemPasskey(requestJson). Run createCredential() with that request through Credential Manager, then answer:
flow.submitSystemPasskeyResponse(registrationResponseJson)
// or, if the user backs out:
flow.declineSystemPasskey()Sign-in. Google Password Manager serves the assertion, so no provider entry point hands your app a client-data hash. Start the face report without one:
val flow = authn.startPendingFaceReportFlow()The server resolves the caller's newest parked assertion. flow.ceremony() returns the adopted rpId and clientDataHashB64url once known, and the flow proceeds exactly as above.
Cross-device sign-in from a desktop
When the user scans the QR a browser shows inside its passkey dialog, the phone proves it is nearby over Bluetooth and signs through a relay. On iOS the operating system owns this path end to end and invokes your provider extension; sign as above and stay out of the way. On Android the SDK runs the ceremony itself.
Both platforms expose senseCryptScannedCodeKind(bytes) so your scanner can tell the two QR formats apart the moment the camera decodes one: platformPasskey for a FIDO:/ code, senseCrypt for anything else, which goes to the login flow.
On Android:
val info = authn.parseHybridQr(qrUrl) // throws if the code is not usable
if (!authn.hasHybridPasskey()) {
// nothing this device can sign with: let the OS handle the code
}
val flow = authn.startHybridCeremonyFlow(qrUrl) // also throws HybridExceptionHybridQrInfo carries tunnelDomain, tunnelIdHex, requestHint, isSignIn and supportsLinking. hasHybridPasskey() is worth asking first: an identity enrolled before the dual-key change holds only a Google Password Manager credential, which this path cannot sign for.
HybridCeremonyFlow reports idle, chooseAccount(accounts), connectingToTunnel(attempt), advertising, waitingForInitiator, handshaking, waitingForCommand, signing, succeeded(rpId, clientDataHashB64url, credentialIdB64url), cancelled and failed(error, retryable), where error is a HybridError. Answer chooseAccount with selectAccount(credentialIdB64url) or dismissAccountChoice(); retry() re-runs from the same scanned code with a fresh tunnel, so the user does not walk back to the desk; cancel() ends it. The Bluetooth advertiser is the SDK's own.
succeeded is half a sign-in. Hand its three values to startFaceReportFlow and the server pairs the parked assertion with the face report.
Related
- Login methods → Passkeys — the model, from the relying party's side.
- Registration — the registration flow, including the system-passkey branch.
- Custom domains — the sign-in host a passkey is bound to.
- Errors — every failure reason.
Face-PKI ceremony flow
Run a face-PKI ceremony with the SenseCrypt Mobile SDK — receive the pushed link, show the approval screen, capture a face, deliver the signed result to the application, and post the completion.
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.