Biometric secrets in Keychain and Keystore: a practical guide
Storing secrets with biometric protection: iOS Keychain+LAContext vs Android Keystore+BiometricPrompt, fallback flows, error handling.

You're shipping a mobile app that needs to store auth tokens, encryption keys, or user data that should survive app deletion but stay locked behind a face or fingerprint. The platform biometric APIs look straightforward in the docs, but the failure modes — enrollment changes, lockout, hardware unavailability — often surface only in production. This guide walks through the concrete mechanics on iOS and Android, the errors you'll actually encounter, and how to test both happy and unhappy paths without a physical device.
Why not just encrypt with a password?
Biometrics are a convenience layer, not a replacement for a strong master secret. The underlying encryption is still handled by the platform's secure enclave: iOS Keychain and Android Keystore each provide hardware-backed storage for cryptographic keys. The difference between the two platforms is how they bind biometric verification to key use. On iOS you create a SecAccessControl that gates the keychain item behind biometric policy. On Android you set key generation parameters such that the key is only usable after user authentication via a CryptoObject tied to BiometricPrompt.
The point of using these native APIs rather than rolling your own with fingerprint scanners or face detection libraries is that the biometric data never leaves the device. The operating system performs the match and releases the encryption key only upon success. Your app never touches raw biometric templates. If you try to abstract this yourself with camera frames or sensor callbacks, you inherit both security and regulatory liability that you don't need.
iOS: Keychain with LAContext
Storing a secret with biometric protection on iOS requires a SecAccessControl object configured with the right flags, and an LAContext for reading.
import Security
import LocalAuthentication
func storeSecretWithBiometrics(_ secret: Data, service: String, account: String) throws {
var error: Unmanaged<CFError>?
guard let accessControl = SecAccessControlCreateWithFlags(
nil,
kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
.biometryCurrentSet,
&error
) else {
throw error!.takeRetainedValue() as Error
}
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecValueData as String: secret,
kSecAttrAccessControl as String: accessControl,
kSecUseAuthenticationContext as String: LAContext()
]
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else {
throw KeychainError.storeFailed(status)
}
}Reading requires an LAContext that you can optionally configure with a fallback button title. The system evaluates biometrics automatically when SecItemCopyMatching encounters a biometric-gated item.
func readBiometricSecret(service: String, account: String) throws -> Data {
let context = LAContext()
context.localizedFallbackTitle = "Use Passcode"
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecReturnData as String: true,
kSecUseAuthenticationContext as String: context,
kSecUseOperationPrompt as String: "Authenticate to access your secret"
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
switch status {
case errSecSuccess:
return result as! Data
case errSecInteractionNotAllowed:
// Biometrics unavailable or user cancelled, fall through
throw KeychainError.interactionNotAllowed
default:
throw KeychainError.readFailed(status)
}
}When errSecInteractionNotAllowed is returned, you can fall back to evaluating LAPolicyDeviceOwnerAuthenticationWithBiometrics or LAPolicyDeviceOwnerAuthentication (passcode) directly, depending on your UX. The default behavior after a user cancels the biometric dialog is to surface the passcode fallback if localizedFallbackTitle is set and the device has a passcode set.
Android: Keystore with BiometricPrompt
Android's approach requires generating an encryption key in the Android Keystore that is only usable after user authentication. You then use BiometricPrompt with a CryptoObject wrapping a cipher initialized from that key.
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.security.keystore.KeyProtection
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
fun generateBiometricKey(keyName: String) {
val keyGenerator = KeyGenerator.getInstance(
KeyProperties.KEY_ALGORITHM_AES,
"AndroidKeyStore"
)
val spec = KeyGenParameterSpec.Builder(
keyName,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
)
.setBlockModes(KeyProperties.BLOCK_MODE_CBC)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_PKCS7)
.setUserAuthenticationRequired(true)
.setInvalidatedByBiometricEnrollment(true)
.build()
keyGenerator.init(spec)
keyGenerator.generateKey()
}
fun getCipherForEncryption(keyName: String): Cipher {
val keyStore = KeyStore.getInstance("AndroidKeyStore")
keyStore.load(null)
val secretKey = keyStore.getKey(keyName, null) as javax.crypto.SecretKey
val cipher = Cipher.getInstance(
"${KeyProperties.KEY_ALGORITHM_AES}/CBC/PKCS7Padding"
)
cipher.init(Cipher.ENCRYPT_MODE, secretKey)
return cipher
}The BiometricPrompt authenticates the user and only then allows the cipher to perform its operation. You pass a CryptoObject wrapping the cipher:
val biometricPrompt = BiometricPrompt(
this, // fragment or activity
ContextCompat.getMainExecutor(this),
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
val cryptoObject = result.cryptoObject
// cipher has been unlocked by authentication
val encryptedBytes = cryptoObject?.cipher?.doFinal(plaintext.toByteArray())
// store encryptedBytes
}
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
// handle error codes
}
}
)
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle("Authenticate")
.setSubtitle("Access your secret")
.setNegativeButtonText("Cancel")
.build()
biometricPrompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher))Before showing the prompt, check whether biometrics are available:
val biometricManager = BiometricManager.from(this)
when (biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG)) {
BiometricManager.BIOMETRIC_SUCCESS -> // show prompt
BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE, BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE ->
// fall back to device credentials or app password
BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED ->
// prompt user to enroll
else -> // general failure
}Platform differences and common abstractions
The two platforms diverge in important ways that affect your key lifecycle logic.
| Aspect | iOS Keychain | Android Keystore |
|---|---|---|
| Enrollment change | Item becomes permanently inaccessible when new biometric is added or existing one removed. You must detect and re-store after fresh biometric auth. | Key is invalidated when biometric enrollments change (default with setInvalidatedByBiometricEnrollment(true)). You must delete and regenerate the key. |
| Passcode fallback | Automatic via localizedFallbackTitle on LAContext. The system prompts for passcode after biometric failure or user taps fallback. |
Requires setDeviceCredentialAllowed(true) in BiometricPrompt.PromptInfo.Builder. You must also set setUserAuthenticationValidityDurationSeconds(0) to require authentication on every key use. |
| Session-based auth | Not directly supported; biometric evaluation occurs on each Keychain read unless you cache the LAContext result (not recommended for security). |
Supported via setUserAuthenticationValidityDurationSeconds(-1) for one-time, or positive duration (Android 8+) to allow key use without re-authentication within the time window. |
| Hardware requirement | Face ID and Touch ID available on all modern devices with Secure Enclave. | Biometric sensor availability varies. Some devices have fingerprint only, some face unlock. Must check BiometricManager. |
React Native libraries like react-native-keychain and react-native-biometrics provide a unified API over these two, but you still need to understand the invalidation semantics to handle key regeneration correctly. If your Android key gets invalidated after an enrollment change and you don't detect it, the next read will fail silently or with a confusing error.
For push notification tokens that also need biometric protection, you may want to read about Push notifications end to end: APNs, FCM and token lifecycle to understand how to store and refresh them securely.
Failure modes and error handling
The most common production bugs come from incomplete error handling. Here is what you can expect and how to handle each case.
Biometric hardware unavailable. On Android, BiometricManager.canAuthenticate() returns BIOMETRIC_ERROR_NO_HARDWARE or BIOMETRIC_ERROR_HW_UNAVAILABLE. On iOS, LAContext.canEvaluatePolicy(_:error:) returns false with LAErrorBiometryNotAvailable. In both cases, fall back to a device passcode or an app-level password. Do not skip the check and show a prompt that immediately fails.
Biometric lockout. After too many failed attempts, the platform temporarily locks out biometric authentication. On Android the BiometricPrompt callback receives BIOMETRIC_ERROR_LOCKOUT (30-second lockout) or BIOMETRIC_ERROR_LOCKOUT_PERMANENT (must use device credentials). On iOS, LAContext.evaluatePolicy returns LAErrorBiometryLockout after 5 failures; the only recovery is device passcode. In both cases, detect the state and degrade to passcode or password without retrying biometrics.
Biometric enrollment changed. This is the most subtle error. On iOS, a previously stored biometric-gated keychain item becomes permanently inaccessible if the user enrolls a new finger or re-enrolls Face ID. The next read returns errSecInteractionNotAllowed or errSecDecode. You must detect this (e.g., by checking for these errors specifically) and prompt for fresh biometric authentication before re-storing the secret. On Android, the key throws KeyStoreException with message "Key user not authenticated" or similar. You need to call keyStore.deleteEntry(keyName) and regenerate the key before the user can store again.
Always have a non-biometric fallback. If neither biometrics nor device credentials are available, the user must use an app password or PIN. This is essential when testing on simulators that don't have biometric hardware.
For more on how app state relates to platform-level lockout, see OTA Updates: What Ships Without Another App Review — the concept of gracefully falling back at runtime applies here too.
Testing biometric flows without a real device
On the Xcode simulator, you can simulate Face ID enrollment and matching. Go to Feature > Face ID > Enrolled to set the enrolled state. Then use Matching Face or Non-matching Face to trigger the biometric callback. Touch ID is not available on the simulator; you must test it on a real device.
On the Android emulator, open the extended controls (... toolbar) and select the Fingerprint tab. You can enroll a finger and then "touch" the fingerprint sensor by clicking the button. Face ID emulation is not available on the emulator — use a Pixel or other device image that includes a fingerprint sensor for testing. If you need to test biometry on a real Pixel device without face hardware, fingerprint is your only option.
For unit tests, mock the platform modules. If you use react-native-keychain, call jest.mock('react-native-keychain') and provide implementations that return the error conditions you want to test. Assert that your fallback paths are called under errSecInteractionNotAllowed or BIOMETRIC_ERROR_LOCKOUT. Do not ship mock implementations that always return true — that pattern will mask production bugs.
Unit testing the Keystore interaction is simpler than testing the biometric prompt. Focus integration tests on the flow: key generation, encryption, biometric prompt success, biometric prompt failure, and key invalidation after enrollment change.
Key takeaways
- Biometric protection does not replace encryption; it gates access to keys stored in platform secure hardware.
- iOS Keychain uses
SecAccessControlwithbiometryCurrentSetandLAContext. Android Keystore usessetUserAuthenticationRequired(true)withBiometricPromptand aCryptoObject. - Enrollment changes permanently invalidate the stored secret on both platforms; you must detect and regenerate after fresh biometric auth.
- Handle lockout, hardware unavailability, and user cancellation explicitly, with a fallback to device passcode or app password.
- Test biometric flows using Xcode simulator Face ID controls and Android emulator fingerprint tab, and mock the platform modules for unit tests to cover error paths.
Frequently asked questions
- How does biometric authentication work when the user adds a new fingerprint?
- If the user adds a new fingerprint or face after storing a secret, iOS revokes the biometric constraint because the biometric database changed. The keychain item becomes inaccessible until the user re-authenticates with the new biometric. Android behaves similarly: keys generated with setUserAuthenticationRequired(true) are invalidated when new biometrics are enrolled. You must re-create the secret after a fresh authentication.
- Can I use biometrics without storing a secret in the keychain?
- Yes, on iOS you can use LocalAuthentication to get a boolean success/failure for biometric verification without storing anything in the keychain. On Android you can call BiometricPrompt.authenticate() with a null CryptoObject. However, this only proves the user is present; it doesn't give you a secret to encrypt data. For most real-world scenarios you want a cryptographic binding between the biometric event and a stored secret.
- What happens if the user's biometrics change after storing a secret?
- On iOS, if the user deletes all fingerprints or Face ID, the keychain item's access control flags become unsatisfiable. You must detect SecItemCopyMatching returning errSecInteractionNotAllowed and prompt for a passcode fallback. On Android, the key is permanently invalidated; BiometricPrompt will return BIOMETRIC_ERROR_NO_BIOMETRICS. You should fall back to device credentials (setDeviceCredentialAllowed(true)) or an app password. Never ignore the invalidation — the old secret is gone.


