Biometric authentication on Android

February 22, 2024

I recently tried out Biometric authentication on Android using the new Biometric library. The new API is really good. The OS now provides a dialog for biometric authentication, making things smoother and more consistent for users. Google even has this neat guide to help get you started with it. Unfortunately it is a bit lackluster, it was not clear to me how to get the whole thing running correctly. I wanted to expand on it and write a better guide on how to store a key for encryption/decryption on the Android keystore and retrieve it using the user’s fingerprint.

The use case I have in mind is the user login. My goal is to provide an alternative to the username/password form with a biometric dialog. After the user’s first successful log in, I’ll encrypt the user’s password and keep it my app’s local storage. The key for encryption/decryption will be stored in the Android keystore and will require biometric authentication for access.

Installation

To use the new library we have to include this line in the app’s build.gradle dependencies block:

Check that biometric authentication is available

Although the new library supports up to API 23, not all devices actually have biometric features. Before attempting anything, we must check if biometric authentication is available using BiometricManager.canAuthenticate():

We pass the BIOMETRIC_STRONG flag because fingerprint is considered strong authentication. The keystore does not allow weak authentication for accessing keys.

This method returns different status codes that we have to handle. If the result is BIOMETRIC_SUCCESS, we are good to go. If the result is BIOMETRIC_ERROR_NONE_ENROLLED, the user needs to enroll a fingerprint on system settings. You can launch an activity for that, but it’s only available on API 30:

Generate a secret key in the Android keystore

Once we have determined the user’s device is ready for biometric authentication, we need to generate a secret key for encryption/decryption. To create it on the Android keystore, we need an instance of KeyGenParameterSpec:

This builder object defines the following things about our key:

Then you can use KeyGenerator to generate it:

Encrypt the user password with the new key

Now that the key is ready, we can use it to create a Cipher object that can encrypt the user password with AES. Passwords should never be stored as plain text. We should wait until the user authenticates with their password, grab it, and then create our cipher:

Please note that this cipher wont work until the user authenticates. Remember that .setUserAuthenticationRequired(true) in the KeyGenParameterSpec builder? We have to pass the cipher to BiometricPrompt so that we can use it after the user authenticates with their fingerprint.

Here we create a BiometricPrompt object,setting the callbacks for events that occur while the user interacts with the dialog. BiometricPrompt.authenticate() takes a PromptInfo object with the text that we want to display and a CryptoObject with our cipher to show the biometric authentication to the user. Now let’s talk about each callback:

In the onAuthenticationFailed() callback we don’t really need to do anything. This gets called every time the fingerprint does not match. The dialog shows this error and lets the user retry multiple times before cancelling the operation.

onAuthenticationError() is for errors that cancel the operation. Maybe the user exceeded the maximum number of attempts, or it got cancelled by user action. Most of the time we can just show the error to the user and exit, but there are a few errors that should be handled differently. You can see this in handleError():

onAuthenticationSucceeded() is where we get our cipher back with a valid key and encrypt the password. Here’s the implementation of encryptAndPersistPassword():

After encrypting there are two byte arrays that we need to persist: the password and the initialization vector (IV). The cipher won’t be able to decrypt the password unless you provide both. For persisting you could use SQLite or just SharedPreferences. It’s far easier to persist strings than byte arrays, so I convert them to Base64 strings. These are the extension methods that I use:

fun ByteArray.toBase64String() = Base64.encodeToString(this, Base64.DEFAULT)

fun String.toBase64ByteArray() = Base64.decode(this, Base64.DEFAULT)

Decrypt the user password

The next time the user wants to sign in we can just decrypt the password and instantly authenticate with the server. Once again, we have to create a Cipher object, but this time in DECRYPT_MODE and pass the IV we persisted at the end of the previous section:

Then, similar to the previous section, we have to create a BiometricPrompt instance and call authenticate():

The BiometricPrompt callback implementations are pretty similar to last time. Error handling on onAuthenticationError() is bit different because ERROR_NEGATIVE_BUTTON now has to revert the UI to the old username/password form.

if (errorCode == ERROR_NEGATIVE_BUTTON) {
    // User clicked negative button to close the dialog
    TODO("revert to username/password form")
    return
}

if (errorCode == ERROR_USER_CANCELED) {
    // User clicked outside the dialog to close it
    return
}

// Show the error to the user
Toast.makeText(this@MainActivity, errString, Toast.LENGTH_LONG).show()
TODO("exit to next screen")

Finally, the code to decrypt the password on onAuthenticationSucceeded() is very straightforward:

And that’s it. We now have biometric authentication in our app. The new biometric prompt has great UX. If something goes wrong, the user can always fallback to the username/password form.