Your fingerprint prompt is a speed bump, not a lock: Android biometrics with a CryptoObject
Most Android biometric prompts just flip a boolean that a patched APK can flip too, while the data sits unencrypted behind it. Bind a Keystore key to BIOMETRIC_STRONG, pass a CryptoObject, and the prompt releases a key the Keystore guards outside your process. Authenticator classes, key settings and KeyPermanentlyInvalidatedException.

A fingerprint prompt feels like security. The user touches the sensor, a little animation plays, the app opens. But look at what most implementations do with the result: onAuthenticationSucceeded() sets a boolean, and the boolean opens the screen. Anyone who can change that boolean, with a debugger on a rooted phone or by patching the APK, opens the screen without a fingerprint. Worse, the data behind the screen was never protected at all; it was sitting in storage the whole time.
A biometric check is only as strong as what it unlocks. This post shows the difference between a prompt that decides a branch in your code and a prompt that releases a cryptographic key, and how to build the second kind with androidx.biometric.
The library
Use the Jetpack Biometric library rather than the platform BiometricPrompt directly. It works back to older Android versions, handles device differences and gives you one API.
dependencies {
implementation("androidx.biometric:biometric:1.1.0")
}
1.1.0 is the stable release in October 2026. The 1.4.0 alpha line adds an activity-result API, a biometric-compose artifact and KeyAgreement support in CryptoObject; everything below uses the stable BiometricPrompt API, which the alpha keeps.
Strong, weak and the screen lock
BiometricManager.Authenticators defines three types, and the difference between the first two is the whole story:
- BIOMETRIC_STRONG: a Class 3 biometric under the Android Compatibility Definition. Strong enough to unlock Keystore keys.
- BIOMETRIC_WEAK: a Class 2 biometric. Can confirm presence, cannot unlock keys.
- DEVICE_CREDENTIAL: the screen lock (PIN, pattern or password).
The device maker decides which sensor is Class 2 or Class 3, not you. Many face unlock implementations are Class 2 and many fingerprint sensors are Class 3, but do not assume; ask the system.
A few rules from the documentation shape everything:
- You cannot call
setNegativeButtonText()together withDEVICE_CREDENTIAL; when the screen lock is allowed, the system provides its own fallback button. - On Android 10 (API level 29) and lower,
DEVICE_CREDENTIALalone andBIOMETRIC_STRONG or DEVICE_CREDENTIALare not supported combinations. Check for a screen lock withKeyguardManager.isDeviceSecure()instead. - Crypto-based authentication is incompatible with Class 2 biometrics, and before Android 11 with the device credential too. Passing a
CryptoObjectwith those authenticators throwsIllegalArgumentException.
Check before you prompt
Call canAuthenticate() with exactly the combination you will pass to the prompt:
fun biometricState(context: Context, allowed: Int): BiometricState {
val manager = BiometricManager.from(context)
return when (manager.canAuthenticate(allowed)) {
BiometricManager.BIOMETRIC_SUCCESS -> BiometricState.Ready
BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> BiometricState.NeedsEnrollment
else -> BiometricState.Unavailable // no hardware, unavailable, update needed
}
}
When nothing is enrolled, on Android 11 (API level 30) and higher you can send the user to Settings.ACTION_BIOMETRIC_ENROLL, passing the authenticators you accept in Settings.EXTRA_BIOMETRIC_AUTHENTICATORS_ALLOWED. When biometrics are unavailable, fall back to your normal sign-in rather than hiding the feature.
The prompt without crypto, and what it proves
The common version calls showNotes() from onAuthenticationSucceeded(). A successful callback proves only that the system said the user passed, and your code then trusts a callback inside your own process. With a patched APK, or on a device where an attacker controls your process, that callback can be faked, and the notes were readable all along. It is fine for confirming that the person holding an already-unlocked phone is probably the owner before showing something mildly sensitive. It is not a lock.
The prompt with crypto: unlocking a key
The fix is to make the prompt release something the app cannot get any other way. Create a key in the Android Keystore that can only be used after a strong biometric check, and encrypt the sensitive data with it. Now the only way to decrypt is to pass the prompt, because the Keystore enforces the check outside your process, often in secure hardware.
Step 1: an authentication-bound key
private const val KEY_ALIAS = "fieldnotes_vault"
fun createVaultKey() {
val spec = KeyGenParameterSpec.Builder(
KEY_ALIAS,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(256)
.setUserAuthenticationRequired(true)
// 0 = authenticate for every use; API 30+ form of the setting
.setUserAuthenticationParameters(0, KeyProperties.AUTH_BIOMETRIC_STRONG)
.setInvalidatedByBiometricEnrollment(true)
.build()
KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore").apply {
init(spec)
generateKey()
}
}
setUserAuthenticationRequired(true)makes the key unusable until the user authenticates.setUserAuthenticationParameters(timeout, type), added in API level 30, sets how long a successful authentication keeps the key usable (0 means every operation needs its own prompt) and which kinds count.AUTH_BIOMETRIC_STRONGaccepts only Class 3 biometrics; addor KeyProperties.AUTH_DEVICE_CREDENTIALto accept the screen lock too. Below API 30, the oldersetUserAuthenticationValidityDurationSeconds()does the same job, with-1meaning per-use.setInvalidatedByBiometricEnrollment(true)(the default, API 24 and higher) permanently invalidates the key when the user enrolls a new fingerprint or face. Someone who learns the PIN cannot add their own finger and then decrypt.
Step 2: prompt with a CryptoObject
Initialize a Cipher with the key, wrap it in a CryptoObject and pass it to authenticate(). On success, the cipher in the result is authorized for one operation:
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
.apply { init(Cipher.ENCRYPT_MODE, vaultKey()) }
val prompt = BiometricPrompt(activity, ContextCompat.getMainExecutor(activity),
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(
result: BiometricPrompt.AuthenticationResult,
) {
val c = result.cryptoObject?.cipher ?: return
onDone(EncryptedBlob(iv = c.iv, data = c.doFinal(plaintext)))
}
})
prompt.authenticate(
BiometricPrompt.PromptInfo.Builder()
.setTitle("Protect your vault")
.setAllowedAuthenticators(BIOMETRIC_STRONG)
.setNegativeButtonText("Cancel")
.build(),
BiometricPrompt.CryptoObject(cipher),
)
Decryption is the same with Cipher.DECRYPT_MODE and a GCMParameterSpec(128, blob.iv). Store the IV next to the ciphertext; it is not secret, but GCM needs it, and the Keystore generates a fresh random IV for each encryption by default.
Step 3: handle a dead key
When the user adds a new fingerprint or removes the screen lock, the key is gone for good and cipher.init() throws KeyPermanentlyInvalidatedException. That is the system doing its job. Catch it, delete the key alias and the ciphertext it protected, and ask the user to sign in again the normal way. Never treat it as a crash:
fun cipherOrNull(mode: Int, iv: ByteArray? = null): Cipher? = try {
Cipher.getInstance("AES/GCM/NoPadding").apply {
if (iv == null) init(mode, vaultKey())
else init(mode, vaultKey(), GCMParameterSpec(128, iv))
}
} catch (e: KeyPermanentlyInvalidatedException) {
KeyStore.getInstance("AndroidKeyStore").apply { load(null) }.deleteEntry(KEY_ALIAS)
vaultStore.clear()
null // caller falls back to a full sign-in
}
Where it belongs
Good uses: unlocking a stored refresh token so a returning user signs in with a fingerprint; confirming a sensitive action such as deleting the account, where the strongest version signs a server nonce with a biometric-bound private key; and a private vault inside the app with its own authentication-bound key.
Bad uses: a prompt in front of the whole app while the data sits unencrypted behind it, or a prompt treated as a replacement for server-side authentication. After too many failed attempts the system locks biometrics out and reports it through onAuthenticationError(); offer the screen lock or your normal sign-in instead of looping the prompt. And if what you want is "sign in with my fingerprint", passkeys are the better answer, since the credential provider already uses the screen lock and biometrics.
Checklist
canAuthenticate()is called with the same authenticators the prompt uses.- Anything sensitive is encrypted with a Keystore key that requires
BIOMETRIC_STRONG. - The prompt passes a
CryptoObject, and only the authorized cipher touches the data. - Keys are invalidated by new biometric enrollment.
KeyPermanentlyInvalidatedExceptionleads to a clean re-sign-in, not a crash.- A failed or locked-out prompt falls back to the screen lock or normal sign-in.
