Biometric Authentication with BiometricPrompt
May 24, 2020
Many apps today offer biometric authentication in addition to username/password authentication. The biometric authentication capabilities are available in devices Android 6 (API level 23) or higher. Prior to Android 8, fingerpint only authentication is available. Starting with Android P (API level 28), you can use a system-provided authentication prompt to request biometric authentication based on device's supported biometric (fingerprint, iris, face, etc). Using the system-provided auth UI results in consistent user interface across all apps on device which benefits the user. It also makes it much easier for developers to implement.
Let’s look a basic example implementation.
Here is our entry point into this demo application:
Clicking the Authenticate button will either present our custom username/password dialog or a biometric login prompt if the device supports biometrics (see above).
For instance, clicking the Authenticate button above when using an emulator, will always bring up the username/password dialog since emulators do not have biometric capabilities.
However, clicking the Authenticate button on my Pixel 2 XL, running Android 10, will bring up a biometric prompt for finger authentication since finger authentication is the only biometric auth supported by this particular device hardware.
Let’s look at the code driving this behavior.
Here is an entire fragment with all the code:
import android.os.Build import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.EditText import androidx.annotation.RequiresApi import androidx.appcompat.app.AlertDialog import androidx.biometric.BiometricManager import androidx.biometric.BiometricPrompt import androidx.core.content.ContextCompat import androidx.fragment.app.Fragment import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar import com.jshvarts.biometricauth.R import kotlinx.android.synthetic.main.main_activity.* import kotlinx.android.synthetic.main.main_fragment.* private const val VALID_USERNAME = "username" private const val VALID_PASSWORD = "password" class MainFragment : Fragment() { private val authenticationCallback = @RequiresApi(Build.VERSION_CODES.P) object : BiometricPrompt.AuthenticationCallback() { override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { super.onAuthenticationSucceeded(result) // Called when a biometric is recognized. onSuccessfulLogin() } override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { super.onAuthenticationError(errorCode, errString) // Called when an unrecoverable error has been encountered and the operation is complete. Snackbar.make(container, R.string.authentication_error_text, Snackbar.LENGTH_LONG) .show() } override fun onAuthenticationFailed() { super.onAuthenticationFailed() // Called when a biometric is valid but not recognized. Snackbar.make(container, R.string.authentication_failed_text, Snackbar.LENGTH_LONG) .show() } } private lateinit var promptInfo: BiometricPrompt.PromptInfo private lateinit var biometricPrompt: BiometricPrompt companion object { fun newInstance() = MainFragment() } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { return inflater.inflate(R.layout.main_fragment, container, false) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) promptInfo = BiometricPrompt.PromptInfo.Builder() .setTitle(getString(R.string.biometric_prompt_title)) .setDescription(getString(R.string.biometric_prompt_description)) .setDeviceCredentialAllowed(true) // user can choose to use device pin in the biometric prompt .build() biometricPrompt = BiometricPrompt( this, ContextCompat.getMainExecutor(context), authenticationCallback ) } override fun onActivityCreated(savedInstanceState: Bundle?) { super.onActivityCreated(savedInstanceState) authenticateButton.setOnClickListener } private fun onSuccessfulLogin() { println("successful login") authenticateButton?.text = getString(R.string.logged_in) } private fun onAuthenticationRequested() { when (BiometricManager.from(requireContext()).canAuthenticate()) { // biometrics available BiometricManager.BIOMETRIC_SUCCESS -> BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE, BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE, BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> requestLoginCredentials() } } private fun requestLoginCredentials() { showLoginDialog { username, password -> // validate login credentials. in the meantime, assume valid credentials are a hardcoded combo if (username == VALID_USERNAME && password == VALID_PASSWORD) else }.show() } private fun showLoginDialog( onPositiveClicked: (username: String, password: String) -> Unit ): AlertDialog { val view = View.inflate(requireContext(), R.layout.alert_dialog_login, null) val usernameEditTextView: EditText = view.findViewById(R.id.username_input_edit_text) val passwordEditTextView: EditText = view.findViewById(R.id.password_input_edit_text) return MaterialAlertDialogBuilder(requireContext()) .setTitle(R.string.dialog_login_title) .setView(view) .setPositiveButton(R.string.dialog_login_positive_button) { _, _ -> onPositiveClicked( usernameEditTextView.text.toString(), passwordEditTextView.text.toString() ) } .setNegativeButton(R.string.dialog_login_negative_button) { _, _ -> } .setCancelable(false) .create() } }
First we set up BiometricPrompt.AuthenticationCallback
which will only be used on Android 8 and above. This callback may be used to respond to successful biometric auth, failed (for instance, fingerprint did not match any registered on device) and error (when system error occurs).
Next, we define the following lateinit var
properties:
private lateinit var promptInfo: BiometricPrompt.PromptInfo private lateinit var biometricPrompt: BiometricPrompt
Then we configure the BiometricPrompt
and allow user to enter PIN instead (setDeviceCredentialAllowed(true)
). Alternatively, the prompt can include a link to password authentication dialog by using .setNegativeButtonText("Use password")
. You can use one or the other only (choice to use a PIN or a password).
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) promptInfo = BiometricPrompt.PromptInfo.Builder() .setTitle(getString(R.string.biometric_prompt_title)) .setDescription(getString(R.string.biometric_prompt_description)) .setDeviceCredentialAllowed(true) // user can choose to use device pin in the biometric prompt .build() biometricPrompt = BiometricPrompt( this, ContextCompat.getMainExecutor(context), authenticationCallback ) authenticateButton.setOnClickListener }
Clicking on the Authenticate button, checks whether biometric is supported and set up by user on this device (BiometricManager.from(requireContext()).canAuthenticate()
returns BiometricManager.BIOMETRIC_SUCCESS
). Note that some devices that had fingerprint scanners before Android officially started supporting them in API Level 23, will return BIOMETRIC_ERROR_HW_UNAVAILABLE
. Updating these devices to 23 and above would not make a difference since these devices don’t comply to Android 6.0 Compat Spec and in particular this part:
“MUST have a hardware-backed keystore implementation, and perform the fingerprint matching in a Trusted Execution Environment (TEE) or on a chip with a secure channel to the TEE.”
private fun onAuthenticationRequested() { when (BiometricManager.from(requireContext()).canAuthenticate()) { // biometrics available BiometricManager.BIOMETRIC_SUCCESS -> BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE, BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE, BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> requestLoginCredentials() } }
Alternatively, a username/password dialog will be shown by calling requestLoginCredentials()
Here is what that flow looks like. We use a material dialog builder to display the credentials prompt and collect username and password supplied by user.
private fun requestLoginCredentials() { showLoginDialog { username, password -> // validate login credentials. in the meantime, assume valid credentials are a hardcoded combo if (username == VALID_USERNAME && password == VALID_PASSWORD) else }.show() } private fun showLoginDialog( onPositiveClicked: (username: String, password: String) -> Unit ): AlertDialog { val view = View.inflate(requireContext(), R.layout.alert_dialog_login, null) val usernameEditTextView: EditText = view.findViewById(R.id.username_input_edit_text) val passwordEditTextView: EditText = view.findViewById(R.id.password_input_edit_text) return MaterialAlertDialogBuilder(requireContext()) .setTitle(R.string.dialog_login_title) .setView(view) .setPositiveButton(R.string.dialog_login_positive_button) { _, _ -> onPositiveClicked( usernameEditTextView.text.toString(), passwordEditTextView.text.toString() ) } .setNegativeButton(R.string.dialog_login_negative_button) { _, _ -> } .setCancelable(false) .create() }
And that’s it. For extra security, you can combine your biometric workflow with cryptography. It’s not in the scope of this post but the details can be found in the official docs.
You can see the entire source code for this example on Github at BiometricAuthenticationDemo