Error reference
Every error type and failure reason the SenseCrypt Mobile SDK can produce, what causes it, and whether it is recoverable.
The SDK has six error surfaces:
| Surface | Type | Where it appears |
|---|---|---|
| Opening the SDK, starting registration | AuthnInitError | Thrown by open() and startRegistrationFlow. |
| Loading models | SenseCryptInitError / SenseCryptInitException | Thrown by initializeInferenceEngine(). |
| Starting a flow | AuthnRuntimeError | Thrown by startRevocationFlow, startKeyRotationFlow. |
| Signing a passkey assertion | SignAssertionError / SignAssertionException | Thrown by signAssertion. |
| Cross-device ceremony (Android) | HybridError / HybridException | Thrown by parseHybridQr and startHybridCeremonyFlow; carried on HybridCeremonyState.failed(error, retryable). |
| Inside a flow | FailureReason | Carried on failed and postingResultFailed states — not thrown. |
FailureReason is the one that matters most in practice: flows don't throw once started, they emit terminal states.
Initialization errors
AuthnInitError — from open()
| Case | Cause | Recoverable? |
|---|---|---|
InvalidPortalUrl(url, details) | portalUrl won't parse. Usually a trailing slash or missing scheme. | Fix the config. Your bug. |
SecureHardwareUnavailable | No usable secure key store on the device. | No. The SDK cannot operate here. Show a terminal message. |
BiometricNotEnrolled | The device has no biometric enrolled. | Yes — send the user to system settings, then retry open(). |
Internal(details) | Unexpected core failure. Also what startRegistrationFlow throws when the flow cannot be constructed. | No. Report it. |
SenseCryptInitError — from initializeInferenceEngine()
| Case | Cause | What to show |
|---|---|---|
LicenseNotFound(path) | No mobile.lic in the bundle or assets. | Nothing — a packaging bug. Fix the project. |
LicenseInvalid(reason) | Present but failed parse or signature check. | Terminal error. |
LicenseExpired | Valid but past expiry. | "Update your app." |
ModelLoad(reason) | A bundled model is missing or corrupt. | Terminal; damaged artifact. |
LivenessInit(reason) | Liveness pipeline data missing or corrupt. | Terminal; damaged artifact. |
LicenseExpired is the only one of these five that is not a bug on your side. A current release ships a fresh license, so an app update is the fix — route users to the store rather than to support.
Runtime errors
AuthnRuntimeError — from flow starters
| Case | Cause | Handling |
|---|---|---|
NoKeys | startKeyRotationFlow() with nothing to rotate. | Hide the rotate action when keys is empty — but still catch this, because the last key can be revoked between your check and the tap. |
UnknownKey(keyId) | startRevocationFlow(keyId:) with an id this device doesn't hold. | Refresh your key list; it's stale. |
FlowFinished(flowId) | An operation was attempted on an already-terminated flow. | Start a new flow. |
Internal(details) | Unexpected core failure. | Report it. |
SignAssertionError — from signAssertion
Thrown by signAssertion (SignAssertionException in Kotlin). Nothing has been shown to the user yet, so handle it in the provider.
| Case | Cause | Handling |
|---|---|---|
NoCredentialForRelyingParty | No passkey on this device for that relying party. The OS only offers a credential once you published an identity for it, so this means a stale publication or a wiped store. | Re-publish identities; do not retry. |
MalformedClientDataHash | The client-data hash is not a 32-byte SHA-256 digest. | Your bug. |
SigningFailed | The secure element refused, or the key is gone, for example after a reinstall wiped Keystore aliases but left the records. | Steer the user to enrol again. |
HybridError — from the cross-device ceremony
Android only. Thrown by parseHybridQr and startHybridCeremonyFlow (HybridException in Kotlin), and carried on HybridCeremonyState.failed(error, retryable). The retryable flag on failed is the classification below, with one more rule: nothing is retryable once the scanned code has gone stale.
| Case | Meaning | Retryable |
|---|---|---|
InvalidQr(detail) | The scanned code is not a usable sign-in code. | No. |
UnknownTunnelServer(id) | The code names a tunnel server the SDK does not know. | No. |
TunnelUnreachable(detail) | The sign-in relay could not be reached. | Yes. |
RoutingIdMissing | The relay did not identify itself. | Yes. |
AdvertiseRefused(detail, retryable) | Bluetooth refused to advertise. retryable says whether the user can fix it (radio off) or not (no radio). | As flagged. |
HandshakeFailed(detail) | The other device could not complete the secure handshake. | Yes. |
InitiatorDropped(beforeHandshake) | The other device disconnected. | Yes. |
ProtocolError(detail) | The other device sent something the SDK could not read. | No. |
NoCredential | This phone has no passkey for that site. | No. Enrol this phone. |
WrongCredential | This phone's passkey is not the one the site asked for. | No. Sign in as the right person. |
RegistrationRefused | A browser cannot create a passkey on this phone. | No. |
AccountSelectionTimedOut | No account was chosen in time; the browser has almost certainly given up. | No. Scan a fresh code. |
PhaseTimeout(phase) | Timed out waiting in the named phase. | Yes. |
Cancelled | The ceremony was cancelled. | No. |
Internal(detail) | Unexpected core failure. | No. Report it. |
Failure reasons
FailureReason is carried on failed(reason:) — terminal — and on postingResultFailed(reason:), which is not terminal and can be retried with retryPostResult().
Network and server
| Reason | Payload | Meaning | Recoverable |
|---|---|---|---|
networkUnreachable | details | Couldn't reach the portal, or the request got no answer. | Yes — on postingResultFailed, retry. |
portalError | details | The portal returned an error. | On postingResultFailed (a 5xx or a 429) — retry. On failed it is the server's verdict on that request: no. |
upgradeRequired | — | The server requires a newer SDK or app version (device protocol 10 or later). This includes a face-PKI ceremony in either scope. | No. Prompt for an app update. |
Credential and session
| Reason | Payload | Meaning | Recoverable |
|---|---|---|---|
tokenExpired | — | The session or token behind the request is gone: expired, already consumed, or unknown (any portal 404, or token_expired / token_invalid). | No — restart the sign-in. |
signatureRejected | — | The portal rejected the device signature. | No. Suggests key or clock trouble; may need re-enrollment. |
clockSkew | — | The device clock is too far off for signed requests to validate. | Yes — tell the user to enable automatic date and time. |
accessDenied | — | The identity is not permitted to complete this sign-in. | No. |
Face and liveness
| Reason | Payload | Meaning | Recoverable |
|---|---|---|---|
faceMismatchMaxRetries | — | The mismatch budget was exhausted. The SDK has already signed an abort to the portal. | No — restart the sign-in. |
livenessFailed | — | Liveness could not be established. | No at the flow level. Within a capture, liveness retries loop back without consuming budget. |
User and device
| Reason | Payload | Meaning | Recoverable |
|---|---|---|---|
biometricDenied | — | The device biometric prompt was refused or failed. | Yes — retry. |
userDeclined | — | The user declined the request. | Terminal by intent. |
loginCancelledByUser | — | Terminal state after cancelLogin(). | Terminal by intent. |
deviceKeyRevoked | email, clientId, appLabel | This device's key was revoked server-side — for example an administrator revoked this device from the console. Surfaced on the device's next signed request. When the user has no application left to enroll through, the same revocation surfaces as deviceKeyTerminated instead. | No — re-enrollment required. Name the app and email from the payload. |
deviceKeyTerminated | email, clientId, appLabel | The key was terminated server-side (a stronger revocation). | No — re-enrollment required. |
deviceKeyReplaced | email, clientId, appLabel | This device's key was revoked because the user added the app on another device, under a tenant that allows one device per user. The key is already wiped, as for deviceKeyRevoked. | No — and do not offer re-enrollment by default: enrolling here again would sign the user's new device out in turn. Say the account moved. |
Registration and signup
| Reason | Payload | Meaning | Recoverable |
|---|---|---|---|
pinAttemptsExhausted | — | Too many wrong PINs. | No — restart registration. |
pinExpired | — | The PIN aged out. | No — restart, or resend before expiry next time. |
emailAlreadyExists | — | Signup for an address that already has an identity. | No — route to sign-in instead. |
signupNotAllowed | — | The application forbids self-signup. | No — direct the user to an administrator. |
accountSuspended | — | The address belongs to a suspended user. Said only once the PIN matched, or at the CIBA start. | No — terminal. Say the account is suspended and to contact an administrator; do not offer self-signup. |
Passkey face report
These end a face report in failed, except the first, which is the one recoverable case.
| Reason | Payload | Meaning | Recoverable |
|---|---|---|---|
ceremonyNotReady | — | The browser half of the passkey sign-in has not been parked yet. | Yes — ask again; the assertion stays live until the pairing window closes. Do not treat it as a network error. |
ceremonyFailed | reason: String | The server says the ceremony is over: 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 the provider 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. |
Face-PKI ceremony
These end a face-PKI ceremony in failed. keyTypeUnavailable, unsupportedAlgorithm and invalidCiphertext are reported only after a face match, because the answer would otherwise tell a stranger something about the user's keys. contextTooLong and invalidPayloadHashLength are refused when the link is submitted, before any face work, and the SDK signs an invalid_request abort for them as it does for a malformed link. None can be fixed on the device: tell the user to contact the application that sent the request.
| Reason | Payload | Meaning | Recoverable |
|---|---|---|---|
keyTypeUnavailable | keyType: PkiKeyType | The user's face token cannot provide a key of this class (kem or dsa). | No. |
unsupportedAlgorithm | name | The user's keys use an algorithm this build of the SDK cannot use. | No — prompt for an app update. |
contextTooLong | — | The requested key context is longer than 255 bytes. | No. |
invalidPayloadHashLength | — | The hash to sign is not 32 or 64 bytes. | No. |
invalidCiphertext | — | The ciphertext does not fit the user's ML-KEM key. | No. |
deliveryFailed | status: UInt16? | The application's endpoint refused the result, or could not be reached (nil). Terminal only after the user gave up in deliveryFailed with cancelCeremony(). | Yes, from deliveryFailed — retry() re-sends the same result. |
In-app sign-in
In-app sign-in carries its own enum, SignInFailure, on failed(reason:) and on postingResultFailed(reason:) (which is not terminal and is retried with retry()). The page's Failures table explains each case; this is the summary.
| Reason | Payload | Meaning | Recoverable |
|---|---|---|---|
signInUnavailable | The IdP does not offer in-app sign-in to this application. There is no browser fallback. | No. Check the console opt-in. | |
upgradeRequired | The IdP needs a newer SDK. | No. Prompt for an app update. | |
notEnrolled | This device holds no usable enrollment for the account. | Yes. Run RegistrationFlow, then sign in again. | |
faceMismatch | The attempt budget is spent, or the IdP refused the proof. | Yes. Start the sign-in again. | |
livenessFailed | The liveness check failed. | Yes. Start the sign-in again. | |
cancelled | cancel() was called. | Nothing to do. | |
network | message | The IdP could not be reached after the automatic retries. | Yes. Retry. |
server | message | The IdP refused a request, the config is invalid, the account's face token is password-protected, or (rate_limited) the start was rate-limited. | For rate_limited, later. Otherwise no. |
pkiNotAvailable | The tenant has no face PKI. | No. Drop the pki request. | |
pkiKeysNotReady | The face token does not hold the keys the operation needs yet. | Yes. One plain sign-in seals them. | |
invalidPkiRequest | The pki request is malformed. | No. Fix the request. | |
pkiScopeNotAllowed | Tenant-wide keys were asked for, but the application is not allowed them or the account's token is bound to no tenant. | No. Use the app scope. |
Two more error types belong to the same flow. ClientAssertionError (Kotlin ClientAssertionException) has one case, Unavailable(details), which a confidential client's ClientAssertionProvider throws when it cannot get an assertion; the SDK treats it like a lost request. RefreshError (Kotlin RefreshException) comes from TokenSession.refresh(): SignInRequired, Network(details) and Server(details). See Refresh errors.
Internal
| Reason | Payload | Meaning |
|---|---|---|
cryptoFailure | details | A cryptographic operation failed. |
internal | details | Unexpected core failure. Also what you get from calling retryPostResult() in a state with no cached inputs. |
In Swift, internal is a keyword — the case is spelled .`internal` with backticks.
Step-specific error enums
These are not failures. They are recoverable conditions carried inside non-terminal states, and the flow stays alive.
LinkError — on awaitingLink
Carried by LoginFlow and PkiCeremonyFlow alike when a submitted link is refused.
| Case | Payload | Meaning | Re-aiming helps? |
|---|---|---|---|
invalidLink | — | Not a SenseCrypt link, or the portal returned a malformed face-token blob. On a ceremony, the request in the link is malformed and the SDK has signed an invalid_request abort. | Yes — keep the scanner open. |
expired | — | The link's expiry timestamp has passed (with a small grace window). It was a real code, it's just dead. | No — surface a "link expired" message and send the user back. |
unknownKey | recipientEmail, appLabel, appLogoBytes, appLogoMime, appPrimaryColor, appAccentColor | The link's (recipient, client_id) pair matches no key on this device. | No — offer "Add key for appLabel?" |
network | — | The lookup couldn't reach the portal. | Yes — retry. |
attestationFailed | — | Play Integrity (Android) / App Attest (iOS) refused to issue a token and the tenant's attestation mode is enforce: the device failed the integrity verdict, your app is missing the required configuration or is not the identity the tenant registered, or attestation is unavailable (simulator). | No — terminal. Show a device-integrity message. |
accessDenied | — | The server's group access gate refused this (recipient, client_id) pair — the user was removed from the app's group, or the group was deleted or detached. | No — terminal. Show "contact your administrator". |
pkiCeremony | — | On a LoginFlow: the link opens a face-PKI ceremony. | Hand the same bytes to a PkiCeremonyFlow. |
signIn | — | On a PkiCeremonyFlow: the link opens a sign-in. | Hand the same bytes to a LoginFlow. |
invalidLink and network are worth keeping the scanner alive for; call resetToScan() to clear the error and resume. pkiCeremony and signIn are hand-offs, not failures: nothing was touched, so start the other flow and submit the same payload. The remaining four cannot be fixed by re-aiming the camera — treat them as end-of-scan and route the user somewhere useful.
unknownKey carries the relying party's branding — logo bytes, MIME type, and colours — precisely so your "add a key?" prompt can render the app's identity rather than a bare client_id. Fall back to an org-initial monogram when appLogoBytes is nil.
attestationFailed fires on the simulator and emulator, where hardware attestation is unavailable, whenever the tenant's attestation mode is enforce. Expect it during local development; it does not mean your integration is broken. A tenant testing its own build keeps attestation on off or audit until the build is in the stores.
PasswordError — on downloadingFaceToken
| Case | Meaning |
|---|---|
wrongPassword | Rejected; re-prompt. |
network | Verification couldn't reach the portal. |
FaceCaptureRetryReason — on awaitingFaceCapture / awaitingCapture
| Case | Meaning | Counts against budget? |
|---|---|---|
faceMismatch | The face didn't match the enrolled token. | Yes. |
livenessFailed | Liveness wasn't established. | No. |
Handling patterns
Distinguish recoverable posts from hard failures. postingResultFailed means the proof is cached and one retryPostResult() away from succeeding — no re-scan, no re-capture. failed means start over. Presenting both as "something went wrong, try again" makes users redo work they don't need to.
Don't build your own retry counter for face mismatches. The budget lives in attemptsUsed / attemptsMax on the capture state, and the SDK signs the abort when it's exhausted. Render the numbers; don't reimplement the logic.
Treat deviceKeyRevoked and deviceKeyTerminated as re-enrollment triggers. Both carry the email and appLabel, so you can say exactly which enrollment is gone rather than showing a generic error.
clockSkew has a real user fix. It's the one cryptographic failure with an obvious remedy — automatic date and time. Say so explicitly instead of showing a generic signing error.
Related
- Initialization — where the init errors surface.
- Login — the retry and cancel semantics in context.
- Reference — the full type reference.