Device-bound passkeys (native apps)

Import a hardware-backed key from your iOS or Android app as a Striga passkey. Striga verifies Apple App Attest or Android Key Attestation before accepting it.

A native iOS or Android app can enrol a hardware-backed key - the Secure Enclave on iOS, the Android Keystore (TEE or StrongBox) on Android - as a Striga passkey without showing the operating-system passkey sheet. The key is released only by the device biometric, your app keeps its own screens and wording, and Striga verifies hardware evidence before accepting the key: Apple App Attest on iOS and Android Key Attestation on Android. Once imported, the key is an ordinary passkey for every SCA ceremony: login, transaction confirmation, trusted beneficiaries, standing orders and passkey removal.

📘

Device-bound passkeys require a separate agreement with LSP EU and a per-platform configuration on your application (see Before you start). Attestation is mandatory: there is no software-only import. Web apps keep using the standard WebAuthn enrolment on /user/passkey/register/start.

When to use it

  • You ship a native app and want the biometric prompt inside your own flow rather than the system passkey sheet.
  • You want the factor pinned to one physical device. A passkey synced through iCloud Keychain or Google Password Manager is not.
  • You already hold a hardware key per user in the app and want it recognised as the SCA factor.

An imported passkey counts towards the user's five-passkey cap and shows up on /user/passkey/list like any other.

Before you start

Send Striga the following for each platform and environment (sandbox and production are configured separately). Until the configuration is in place the import endpoints answer 100010.

PlatformWhat Striga needs
iOSApple Team ID, the bundle identifiers allowed to import, and the App Attest environment: development for builds whose com.apple.developer.devicecheck.appattest-environment entitlement is development, production for App Store and TestFlight builds. A mismatch fails the attestation with 100012.
AndroidThe package names allowed to import and the signing-certificate digests: standard base64 of the SHA-256 over the DER signing certificate. apksigner verify --print-certs app.apk prints the hex; with Play App Signing use the certificate Play signs with (Play Console → App signing). Optional policy: minSecurityLevel (TRUSTED_ENVIRONMENT, the default, or STRONG_BOX), requireVerifiedBoot (on by default: verified boot and a locked bootloader), allowExpiredFactoryChains (off by default).
Bothpasskey enabled as an SCA method for the application, and the WebAuthn relying party id plus allowed origins configured as for web passkeys. The assertions your app builds carry an origin, and it must be on that allow-list.

How it fits together

  1. POST /user/passkey/import/start returns a 32-byte challenge. It lives 10 minutes and is single use.
  2. On the device, create the key and bind that exact challenge into the platform attestation.
  3. Sign a short proof of possession with the new key, behind the biometric prompt.
  4. POST /user/passkey/import/complete with the public key, the attestation and the proof. Striga verifies everything and returns the credential.
  5. From then on the app answers SCA challenges with a WebAuthn assertion it builds itself (see Using the imported passkey).
🔑

Two keys on iOS, one on Android. The App Attest key attests your app and is never the passkey. The passkey is a second Secure Enclave key, created by you with biometric access control. On Android the attested Keystore key is the passkey key, and Striga checks that the public key in the attestation matches the one you import.

Step 1 - Start

POST /user/passkey/import/start
Authorization: <HMAC>

{ "userId": "<uuid>", "platform": "IOS" }
{
  "challengeId": "40c2e13f-7f6c-479a-a251-7539a8c77f91",
  "challenge": "aqX3G1y...wZ0",
  "expiresAt": "2026-10-20T06:05:15.000Z",
  "relyingPartyId": "app.example.com"
}

platform is IOS or ANDROID. challenge is base64url without padding; decode it to 32 bytes before using it on the device. relyingPartyId is the rpId your assertions must hash later. Starting a new import replaces any unfinished one for the same user. If the user already holds five passkeys the call fails with 100004; remove one first (see Managing passkeys).

Step 2 - Create the key and attest it

iOS

let challenge = Data(base64urlEncoded: start.challenge)!                      // 32 bytes

// App Attest: attests the app, not the passkey. A key can be attested once; generate a new one per import.
let keyId = try await DCAppAttestService.shared.generateKey()
let attestation = try await DCAppAttestService.shared.attestKey(
    keyId, clientDataHash: Data(SHA256.hash(data: challenge)))

// The passkey key: Secure Enclave, P-256, released only by the current biometric set.
let access = SecAccessControlCreateWithFlags(
    nil, kSecAttrAccessibleWhenUnlockedThisDeviceOnly, [.privateKeyUsage, .biometryCurrentSet], nil)!
let attrs: [String: Any] = [
    kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
    kSecAttrKeySizeInBits as String: 256,
    kSecAttrTokenID as String: kSecAttrTokenIDSecureEnclave,
    kSecPrivateKeyAttrs as String: [
        kSecAttrIsPermanent as String: true,
        kSecAttrApplicationTag as String: "com.example.striga.passkey".data(using: .utf8)!,
        kSecAttrAccessControl as String: access]]
let privateKey = SecKeyCreateRandomKey(attrs as CFDictionary, nil)!
let publicKey = SecKeyCopyExternalRepresentation(SecKeyCopyPublicKey(privateKey)!, nil)! as Data   // 65-byte X9.63 point

Send keyId and attestation exactly as DeviceCheck returns them (standard base64), the bundle id of the running app in app.bundleId, and publicKey as base64url. The clientDataHash must be the SHA-256 of the decoded challenge bytes; Striga recomputes it and refuses anything else (100012, step NONCE). App Attest is not available on the simulator.

Android

val challenge = Base64.decode(start.challenge, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)

val spec = KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_SIGN)
    .setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1"))
    .setDigests(KeyProperties.DIGEST_SHA256)
    .setUserAuthenticationRequired(true)
    .setUserAuthenticationParameters(0, KeyProperties.AUTH_BIOMETRIC_STRONG)   // per use, biometric only
    .setInvalidatedByBiometricEnrollment(true)
    .setAttestationChallenge(challenge)
    // .setIsStrongBoxBacked(true)   // optional; catch StrongBoxUnavailableException and retry without it
    .build()
val keyPair = KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_EC, "AndroidKeyStore")
    .apply { initialize(spec) }.generateKeyPair()

val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
val chain = keyStore.getCertificateChain(alias)       // leaf first; send each as PEM or base64 DER
val publicKey = keyPair.public.encoded                // SPKI DER

The key must satisfy Striga's policy or the import fails with 100012 and a message naming the rule: EC P-256, generated on the device, user authentication required with biometric only and per use (a key that also accepts the PIN, pattern or password, or that carries an authTimeout, is refused), security level TEE or StrongBox, verified boot VERIFIED with a locked bootloader, the package name and signing digest on your allow-list, and a chain to a Google hardware attestation root that is not revoked. Send 2 to 8 certificates, leaf first.

Step 3 - Sign the proof

Build this JSON, with exactly these members, and keep the bytes you sign:

{
  "type": "striga.passkey.import",
  "challenge": "<challenge from step 1, as received>",
  "challengeId": "<challengeId from step 1>",
  "userId": "<userId>",
  "credentialId": "<base64url of 16 to 64 random bytes chosen by the app>",
  "publicKeySha256": "<base64url SHA-256 of the raw public-key bytes sent in passkey.publicKey>"
}

proof.clientData is the base64url of those bytes. proof.signature is the base64url of a DER-encoded ECDSA-SHA256 signature over the same bytes, made by the passkey key - SecKeyCreateSignature with .ecdsaSignatureMessageX962SHA256 on iOS, Signature.getInstance("SHA256withECDSA") through a BiometricPrompt.CryptoObject on Android. This is the user's first biometric prompt. publicKeySha256 is hashed over the decoded bytes of passkey.publicKey, whichever encoding you chose. The business routes use businessId in place of userId. Store credentialId next to the key: it becomes the WebAuthn credential id for every later assertion.

Step 4 - Complete

POST /user/passkey/import/complete
Authorization: <HMAC>

{
  "userId": "<uuid>",
  "challengeId": "40c2e13f-7f6c-479a-a251-7539a8c77f91",
  "platform": "IOS",
  "app": { "bundleId": "com.example.app" },
  "passkey": {
    "credentialId": "xZoUWGm1ThJz4Ulir1l6HA",
    "publicKey": "BGt5...X9.63 point or SPKI DER, base64url...",
    "algorithm": -7,
    "displayName": "iPhone"
  },
  "attestation": { "type": "APP_ATTEST", "keyId": "<standard base64>", "attestation": "<standard base64 CBOR>" },
  "proof": { "clientData": "<base64url JSON>", "signature": "<base64url DER ECDSA>" }
}

On Android, app is { "packageName": "com.example.app" } and attestation is { "type": "ANDROID_KEY_ATTESTATION", "certificateChain": ["<leaf PEM>", "...", "<root PEM>"] }.

{
  "credential": {
    "id": "15a3ed76-8e07-4df3-a251-ae92c21678e1",
    "credentialId": "xZoUWGm1ThJz4Ulir1l6HA",
    "displayName": "iPhone",
    "name": "native:ios:xZoUWGm1",
    "relyingPartyId": "app.example.com",
    "transports": ["internal"],
    "insertInstant": 1792472958660,
    "lastUseInstant": 1792472958660
  },
  "attestation": { "type": "APP_ATTEST", "environment": "production" },
  "multiFactorAuth": { "enabled": true, "primaryMethod": "passkey", "enrolledMethods": ["passkey"], "passkeyCount": 2 }
}

The attestation summary is { type: "APP_ATTEST", environment } on iOS and { type: "ANDROID_KEY_ATTESTATION", securityLevel } on Android, where securityLevel is TRUSTED_ENVIRONMENT or STRONG_BOX. The challenge is single use: once the request passes validation and the passkey-cap check it is consumed whether or not the import succeeds, so after a 100011, 100012, 999995 or 999998 start again from step 1.

FieldConstraint
passkey.credentialIdbase64url of 16 to 64 random bytes, chosen by the app; must be unique
passkey.publicKeybase64url of the 65-byte X9.63 uncompressed P-256 point or of the SPKI DER
passkey.algorithm-7 (ES256) only
passkey.displayNameoptional, 1 to 64 characters, shown on the passkey list
attestation.keyIdiOS, standard base64 as returned by generateKey()
attestation.attestationiOS, standard base64, up to 64 KB
attestation.certificateChainAndroid, 2 to 8 entries, leaf first, each PEM or base64 DER up to 16 KB
proof.clientDatabase64url JSON, up to 4 KB
proof.signaturebase64url DER ECDSA, up to 256 characters

Using the imported passkey

There is no browser, so your app builds the WebAuthn assertion itself and sends it wherever a passkey assertion is accepted: /user/login/complete, /wallets/transaction/confirm, trusted-beneficiary and standing-order confirmations, and /user/passkey/delete/confirm. The challenge and the allowCredentials list come from the corresponding start call, for example /wallets/passkey/approval/start.

{
  "id": "<credentialId>",
  "rawId": "<credentialId>",
  "type": "public-key",
  "authenticatorAttachment": "platform",
  "response": {
    "clientDataJSON": "<base64url of {\"type\":\"webauthn.get\",\"challenge\":\"<challenge>\",\"origin\":\"https://app.example.com\"}>",
    "authenticatorData": "<base64url of SHA-256(rpId) || 0x05 || counter as 4 bytes big-endian>",
    "signature": "<base64url DER ECDSA-SHA256 over authenticatorData || SHA-256(clientDataJSON)>"
  },
  "clientExtensionResults": {}
}
  • rpId is the relyingPartyId returned at import; origin must be one of your application's allowed WebAuthn origins.
  • The flags byte 0x05 declares user presence and user verification; the biometric prompt that releases the key is the verification.
  • Keep a per-credential signature counter and increase it on every assertion. Striga accepts a counter that stays at 0, but once it is above zero it must strictly increase; an equal or lower value is refused.
  • Omit response.userHandle. A device-bound key has no user handle to report; Striga drops the member for imported credentials.
  • Pass the client ip and, for transaction confirmations, the same verificationMethod: "passkey" and origin as for any passkey.

Per-approval App Attest assertion (iOS, optional)

An iOS app may additionally prove that the request comes from the attested app instance by adding passkeyAssertion.deviceAttestation = { "keyId", "assertion" }, where assertion is the standard base64 of DCAppAttestService.generateAssertion(keyId, clientDataHash: SHA256(clientDataJSON bytes)) with the App Attest key enrolled at import. When present it is verified before the WebAuthn check: the key must be the one bound to this credential, the assertion must be over this exact clientDataJSON, and the App Attest counter must have increased. A replayed or mismatched assertion fails the confirmation; the member is removed before the assertion reaches the WebAuthn verifier. Android keys have no App Attest equivalent; a deviceAttestation sent with an Android credential is ignored.

📘

On confirmation endpoints a failed deviceAttestation surfaces as the generic factor error 31028; the message names the failing step, for example Attestation failed (SIGNATURE): signature does not verify over the nonce. On /user/login/complete the same failure keeps its own code, 100012, as on /passkey/import/complete.

Lifecycle and fallbacks

  • Keep SMS or TOTP enrolled. Attestation fails on a small share of genuine devices - unlocked bootloaders, custom ROMs, devices whose attestation chain has expired, or an App Attest outage - and the user must still be able to transact. Treat 100012 as "offer another factor", not as fraud.
  • Keys do not survive the device. A Secure Enclave or Keystore key is lost on uninstall, on a device restore to new hardware, and when biometrics are re-enrolled (.biometryCurrentSet, setInvalidatedByBiometricEnrollment). Detect the missing key, remove the stale credential with /user/passkey/delete + /user/passkey/delete/confirm using a remaining factor, then import again. The 2FA reset also clears device bindings.
  • One import per credential id. A credentialId already in use, from an earlier import or any other passkey, is refused with 999998; generate a fresh one.
  • Five passkeys per user. A sixth import fails with 100004 at start and again at complete.
  • New App Attest key per import. Apple allows a key to be attested once; reuse fails. The passkey key, by contrast, lives as long as the credential.

Business accounts

POST /business/passkey/import/start and POST /business/passkey/import/complete follow the same contract keyed by businessId, including businessId in the proof's clientData. The imported key is then used on the business login and confirmation endpoints.

Errors

CodeWhereMeaning
00002start, completeRequest validation failed; message lists the fields (Invalid fields --> Invalid attestation.keyId, …).
100004start, completePasskey limit reached (five per user or business). Remove one first.
100010start, completeDevice-bound passkeys are not configured for this platform on your application. Contact Striga.
100011completeProof invalid: a clientData member does not match, the public key cannot be parsed, or the signature does not verify.
100012complete, login completeAttestation failed. The message names the step: INPUT, FORMAT, APP_ID / PACKAGE (not on the allow-list), NONCE (wrong challenge), CHAIN / ROOT, AUTH_DATA, RP_ID, POLICY (Android key rules), KEY_BINDING (attested key is not the passkey key), COUNTER.
999995completeNo import in progress, the challenge expired or was already used, or challengeId / platform do not match it. Start again.
999998completecredentialId already in use.
31028confirmationsThe optional deviceAttestation failed on a transaction or other confirmation endpoint (the message names the step), or another factor error on the assertion. On /user/login/complete the same failure is returned as 100012.

Testing on sandbox

  • Sandbox and production hold separate configurations. For sandbox, send the Team ID, bundle id and development environment of your development builds, or the package name and the digest of the certificate your debug builds are signed with.
  • App Attest needs a physical iPhone; Android Key Attestation needs a physical device with a locked bootloader and enrolled biometrics (emulators fail POLICY).
  • The sandbox SMS code 123456 lets you exercise the passkey-removal and fallback paths without a phone number.