How do you implement biometric authentication (Face ID/fingerprint) correctly and safely?
Face ID and fingerprint prompts feel like a yes/no gate, but the secure implementation is really about releasing a hardware-backed key — get that distinction wrong and the 'security' feature becomes trivially bypassable.
LEARN BY DOINGPut your intuition to work3 steps
How do you implement biometric authentication (Face ID/fingerprint) correctly and safely?
Use the lesson’s examples and explanation to check your idea.
Personal reflection. Nothing is sent to an AI service.#Biometrics unlock a key, not a boolean
The number one mistake in biometric auth is treating LocalAuthentication/BiometricPrompt as a fancy if (userIsWhoTheySayTheyAre) check. The right mental model is different: Face ID, Touch ID, and Android's fingerprint/face APIs exist to release access to a hardware-backed cryptographic key that lives in the Secure Enclave (iOS) or the Trusted Execution Environment behind the Android Keystore. The biometric sensor never leaves the device, the app never receives raw fingerprint or face data, and — critically — a "success" result should never, by itself, be the thing that unlocks a resource. It should be the thing that lets a key be used for one cryptographic operation.
#How the platforms actually wire this up
On iOS you create an asymmetric key pair (or a Keychain item) with a SecAccessControl that requires biometrics, then any operation against that key — signing, decrypting — triggers the system Face ID/Touch ID sheet automatically. On Android you generate a key in the AndroidKeyStore with setUserAuthenticationRequired(true), wrap a Cipher around it, and hand that Cipher to BiometricPrompt as a CryptoObject. In both cases the OS refuses to let the key do anything until the biometric check passes — the app code never evaluates "was the fingerprint correct," it only ever gets to try to use a key that is inert until unlocked.
import LocalAuthenticationimport Securityfunc makeBiometricProtectedKey() throws {let access = SecAccessControlCreateWithFlags(kCFAllocatorDefault,kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,[.privateKeyUsage, .biometryCurrentSet], // invalidated if enrolled biometrics changenil)!let attributes: [String: Any] = [kSecClass as String: kSecClassKey,kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,kSecAttrKeySizeInBits as String: 256,kSecAttrTokenID as String: kSecAttrTokenIDSecureEnclave,kSecPrivateKeyAttrs as String: [kSecAttrIsPermanent as String: true,kSecAttrApplicationTag as String: "com.example.app.biometric-signing-key",kSecAttrAccessControl as String: access,],]var error: Unmanaged<CFError>?guard SecKeyCreateRandomKey(attributes as CFDictionary, &error) != nil else {throw error!.takeRetainedValue()}}
val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")keyGenerator.init(KeyGenParameterSpec.Builder("biometric_session_key",KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT).setBlockModes(KeyProperties.BLOCK_MODE_GCM).setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE).setUserAuthenticationRequired(true).setInvalidatedByBiometricEnrollment(true).build())keyGenerator.generateKey()val cipher = Cipher.getInstance("AES/GCM/NoPadding")val secretKey = keyStore.getKey("biometric_session_key", null) as SecretKeycipher.init(Cipher.ENCRYPT_MODE, secretKey)val prompt = BiometricPrompt(activity, executor, object : BiometricPrompt.AuthenticationCallback() {override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {val unlockedCipher = result.cryptoObject?.cipher ?: returnval encryptedSessionToken = unlockedCipher.doFinal(sessionSecretBytes)// only now do we have something usable — there is no boolean to fake}})prompt.authenticate(BiometricPrompt.PromptInfo.Builder().setTitle("Confirm it's you").setSubtitle("Unlock your saved session").setNegativeButtonText("Use account password").build(),BiometricPrompt.CryptoObject(cipher))
#The anti-pattern almost everyone ships first
The naive version compiles, passes QA, and is trivially defeatable on a jailbroken or rooted device:
// ANTI-PATTERN — do not ship thislet context = LAContext()context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: reason) { success, _ inif success {self.isUnlocked = true // a plain flag, backed by nothing}}
The problem isn't that Face ID can be spoofed with a photo (in practice, it can't) — it's that on a jailbroken or rooted device, tools like Frida or objection can hook evaluatePolicy's completion handler or BiometricPrompt's callback and force success = true unconditionally, no fingerprint required. If isUnlocked = true is the only thing standing between an attacker and your app's protected screen or wallet balance, that hook is the entire attack. When biometrics gate a real cryptographic key instead, the same hook gets the attacker nothing: the key material still lives in the Secure Enclave/TEE and the app still can't produce a valid signature or decrypt a valid payload without it.
#Invalidate keys when enrollment changes
If someone adds a new fingerprint or face to the device — including an attacker who has physical access and enough time to enroll their own — a key created with .biometryCurrentSet (iOS) is automatically invalidated, so it can no longer be unlocked at all; the user has to re-authenticate through a stronger factor and re-provision the key. The equivalent on Android is setInvalidatedByBiometricEnrollment(true) on the KeyGenParameterSpec. Using .biometryAny/leaving that flag off means the key survives new enrollments, which is sometimes what you want (family-shared devices) but is the wrong default for anything protecting money or personal data.
#Pairing local biometrics with server trust
For anything that talks to a backend, local unlock alone is not authentication to the server — it's authentication to the device. The pattern that holds up under review: the biometric-protected key signs a nonce or short-lived challenge issued by your backend, and the server verifies that signature against a public key it registered during a real login (password + MFA) earlier. That turns "Face ID succeeded" into "the private key that only this Secure Enclave holds produced a valid signature over this server-issued challenge," which a hooked boolean can never fake because the attacker still doesn't have the private key.
#Fallbacks and edge cases you'll be asked about
Always design an explicit fallback path — device passcode, or your app's own PIN — for devices without biometric hardware, for users who've disabled it, and for the OS-enforced lockout after too many failed attempts (which iOS and Android both own; don't try to reimplement attempt counting yourself). Never make biometrics the only way in, and never silently downgrade to "no auth at all" when the hardware isn't available — that's a common regression that ships quietly because it only shows up on older or entry-level devices QA didn't test on.
🧠 Biometric authentication
0/3 · 0/3 answered1. Why is `if (biometricSuccess) { isUnlocked = true }` considered an anti-pattern?
2. What does `.biometryCurrentSet` (iOS) / `setInvalidatedByBiometricEnrollment(true)` (Android) protect against?
3. For a backend-connected feature, what should a successful local biometric check ultimately produce?