Free weekly email: the week's new posts, videos and one tip from the books, every Sunday. Get it free →
Homelab the Right Way

Home › Posts

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.

· 5 min read · androidsecuritybiometricskeystorekotlinandroidx biometricencryption

Your fingerprint prompt is a speed bump, not a lock: Android biometrics with a CryptoObject

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 with DEVICE_CREDENTIAL; when the screen lock is allowed, the system provides its own fallback button.
  • On Android 10 (API level 29) and lower, DEVICE_CREDENTIAL alone and BIOMETRIC_STRONG or DEVICE_CREDENTIAL are not supported combinations. Check for a screen lock with KeyguardManager.isDeviceSecure() instead.
  • Crypto-based authentication is incompatible with Class 2 biometrics, and before Android 11 with the device credential too. Passing a CryptoObject with those authenticators throws IllegalArgumentException.

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_STRONG accepts only Class 3 biometrics; add or KeyProperties.AUTH_DEVICE_CREDENTIAL to accept the screen lock too. Below API 30, the older setUserAuthenticationValidityDurationSeconds() does the same job, with -1 meaning 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.
  • KeyPermanentlyInvalidatedException leads 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.

More articles