Biometric Authentication in Android

Modern Android devices support a wide range of authentication mechanisms, starting with a simple pin lock and all the way to using built-in sensors for biometric authentication. In this post, I’ll show you how to implement a basic biometric authentication in your Android application, and then demonstrate how to organize the resulting logic in a “clean” way.

Jetpack Biometric Library and BiometricPrompt

This tutorial will use BiometricPrompt component from Jetpack Biometric library. It makes the implementation of biometric authentication relatively straightforward, but works only on Android 6.0 (API level 23) or later. Therefore, if your need to support earlier versions of Android, you’ll need look for a different approach.

To use Biometric library in your project, please add either of the below lines to your Gradle dependencies configuration (make sure to check for the latest version when you read this guide):

implementation "androidx.biometric:biometric-ktx:1.2.0-alpha05" // for Kotlin projects
implementation "androidx.biometric:biometric:1.2.0-alpha05" // for Java projects

Checking the State of Biometric Authentication

Before you authenticate the user, you want to check the state of biometric authentication feature. This will allow you to know if the user hasn’t set up biometric credentials on their device yet, or if their device doesn’t support biometric authentication at all.

First, initialize an instance of androidx.biometric.BiometricManager class:

val biometricManager = BiometricManager.from(context)

Then use BiometricManager to find out whether authentication is possible:

private fun handleBiometricAuthState() {
    when (biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG)) {
        BiometricManager.BIOMETRIC_SUCCESS -> authenticate()
        BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> launchBiometricEnrollment()
        else -> { /* handle biometric auth not possible */ }
    }
}

Note that I pass BIOMETRIC_STRONG as the argument. Depending on your use cases and constraints, you can also use BIOMETRIC_WEAK or even DEVICE_CREDENTIAL values.

Redirecting the User to Set Up Biometric Authentication

If the user doesn’t have any credentials set up on the device, you can redirect them to perform the initial setup in this manner:

private val activityResultHandler = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
    if (result.resultCode == Activity.RESULT_CANCELED) {
        // handle biometric setup cancellation
    } else {
        handleBiometricAuthState()
    }
}

private fun launchBiometricEnrollment() {
    val intent: Intent = when {
        Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> {
            Intent(Settings.ACTION_BIOMETRIC_ENROLL).putExtra(
                Settings.EXTRA_BIOMETRIC_AUTHENTICATORS_ALLOWED,
                BiometricManager.Authenticators.BIOMETRIC_STRONG
            )
        }
        else -> Intent(Settings.ACTION_SECURITY_SETTINGS)
    }
    activityResultHandler.launch(intent)
}

Note that on earlier Android versions you can’t redirect the user to the enrollment flow itself. The best you can do is to take them to security settings screen. Therefore, consider providing additional explanation before you open the settings app so that the user will understand what they need to do on that screen.

Performing Basic Biometric Authentication

Once BiometricManager.canAuthenticate() call returns the result of BiometricManager.BIOMETRIC_SUCCESS, you can proceed to the actual authentication flow.

However, due to unusual behavior in the context of Android lifecycles, you need to instantiate BiometricPrompt in advance, preferably in onCreate() method of the containing Activity or Fragment:

private var biometricPrompt: BiometricPrompt? = null

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    initBiometricPrompt()
}

private fun initBiometricPrompt() {
    biometricPrompt = BiometricPrompt(
        requireActivity(),
        object : BiometricPrompt.AuthenticationCallback() {
            override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
                super.onAuthenticationError(errorCode, errString)
                val cancelled = errorCode in arrayListOf<Int>(
                    BiometricPrompt.ERROR_CANCELED,
                    BiometricPrompt.ERROR_USER_CANCELED,
                    BiometricPrompt.ERROR_NEGATIVE_BUTTON
                )
                if (cancelled) {
                    // handle authentication cancelled
                } else {
                    // handle authentication failed
                }
            }

            override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
                super.onAuthenticationSucceeded(result)
                // handle authentication succeeded
            }

            override fun onAuthenticationFailed() {
                super.onAuthenticationFailed()
                // this method will be invoked on unsuccessful intermediate attempts (e.g. unrecognized fingerprint)
            }
        }
    )
}

Then, to request biometric authentication, you simply do:

private fun authenticate() {
    val promptInfo = BiometricPrompt.PromptInfo.Builder()
        .setTitle("Biometric authentication")
        .setSubtitle("")
        .setDescription("Please confirm your credentials)
        .setNegativeButtonText("Cancel")
        .setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG)
        .build()

    biometricPrompt.authenticate(promptInfo)
}

The result of the authentication flow will be delivered to the AuthenticationCallback object that you provided while instantiating your BiometricPrompt. The naming of its methods can be a bit confusing, so let’s clarify their meaning:

  • onAuthenticationSucceeded: called on successful authentication (no surprise here).
  • onAuthenticationFailed: called on every unsuccessful authentication attemp; note that this is not the final “failed” indication.
  • onAuthenticationError: this is the final “failed” indication; despite its name, this method will also be called if the user cancels the authentication flow (which isn’t an error per se).

Experienced developers looking at the above code can have concerns about the reliability and the safety of this approach in the context of Android lifecycles, and for the potential memory leaks. Such concerns are justified and, indeed, there were problems in the past. However, today, this component looks pretty robust to me and I haven’t experienced any issues with its latest versions so far.

Clean Design of Biometric Authentication

While the above implementation works, it’s not a “clean code”, due to (at least) two reasons:

  • There is a considerable amount of a “boilerplate” code which pollutes your UI controllers (Activities, Fragments, etc.).
  • You’ll need to duplicate all this code in every place in your app that requires biometric authentication.

We can tackle both of these problems by extracting a standalone use case class for biometric authentication:

class BiometricAuthUseCase(
    private val activity: FragmentActivity,
    private val biometricManager: BiometricManager,
): Observable<BiometricAuthUseCase.Listener>() {

    sealed class AuthResult {
        object NotEnrolled: AuthResult()
        object NotSupported: AuthResult()
        data class Failed(val errorCode: Int, val errorMessage: String): AuthResult()
        object Cancelled: AuthResult()
        object Success: AuthResult()
    }

    interface Listener {
        fun onBiometricAuthResult(result: AuthResult)
    }

    private val biometricPrompt = BiometricPrompt(
        activity,
        object : BiometricPrompt.AuthenticationCallback() {
            override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
                super.onAuthenticationError(errorCode, errString)
                val cancelled = errorCode in arrayListOf<Int>(
                    BiometricPrompt.ERROR_CANCELED,
                    BiometricPrompt.ERROR_USER_CANCELED,
                    BiometricPrompt.ERROR_NEGATIVE_BUTTON
                )
                if (cancelled) {
                    listeners.map { it.onBiometricAuthResult(AuthResult.Cancelled) }
                } else {
                    listeners.map { it.onBiometricAuthResult(AuthResult.Failed(errorCode, errString.toString())) }
                }
            }

            override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
                super.onAuthenticationSucceeded(result)
                listeners.map { it.onBiometricAuthResult(AuthResult.Success) }
            }

            override fun onAuthenticationFailed() {
                super.onAuthenticationFailed()
            }
        }
    )

    fun authenticate(title: String, description: String, negativeButtonText: String) {
        when (biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG)) {
            BiometricManager.BIOMETRIC_SUCCESS -> { /* proceed */ }
            BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> {
                listeners.map { it.onBiometricAuthResult(AuthResult.NotEnrolled) }
                return
            }
            else -> {
                listeners.map { it.onBiometricAuthResult(AuthResult.NotSupported) }
                return
            }
        }

        val promptInfo = BiometricPrompt.PromptInfo.Builder()
            .setTitle(title)
            .setSubtitle("")
            .setDescription(description)
            .setNegativeButtonText(negativeButtonText)
            .setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG)
            .build()

        biometricPrompt.authenticate(promptInfo)
    }
}

You can take the implementation of Observable base class from here, or simply remove this inheritance and implement a basic Observer design pattern right inside this class.

Once you have the above BiometricAuthUseCase class in your project, implementing biometric authentication becomes very straightforward:

private lateinit var biometricAuthUseCase: BiometricAuthUseCase

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    biometricAuthUseCase = BiometricAuthUseCase(requireActivity(), BiometricManager.from(requireContext()))
}

override fun onStart() {
    super.onStart()
    biometricAuthUseCase.registerListener(this)
}

override fun onStop() {
    super.onStop()
    biometricAuthUseCase.unregisterListener(this)
}

private fun authenticate() {
    biometricAuthUseCase.authenticate(
        getString(R.string.biometric_auth_title),
        getString(R.string.biometric_auth_description),
        getString(R.string.cancel),
    )
}

override fun onBiometricAuthResult(result: BiometricAuthUseCase.AuthResult) {
    when(result) {
        is BiometricAuthUseCase.AuthResult.NotEnrolled -> {
            launchBiometricEnrollment()
        }
        is BiometricAuthUseCase.AuthResult.NotSupported -> {
            // handle biometric auth not possible
        }
        is BiometricAuthUseCase.AuthResult.Success -> {
            // handle biometric auth succeeded
        }
        is BiometricAuthUseCase.AuthResult.Cancelled -> {
           // handle biometric auth cancelled
        }
        is BiometricAuthUseCase.AuthResult.Failed -> {
            // handle biometric auth failed (this is the "final" failed callback)
        }
    }
}

That’s much less code inside UI controllers than we had before, so it’s alright to have it in multiple places inside your app.

Dependency Injection with Dagger and Hilt Course

Learn Dependency Injection in Android and master Dagger and Hilt dependency injection frameworks.

Go to Course

Summary

Now you know how to implement biometric authentication in your Android application while avoiding unneeded boilerplate and code duplication. The above code examples will get you started, but you’ll probably need to modify them a bit according to your specific requirements.

Thanks for reading. Please leave your comments and questions below.

Check out my premium

Android Development Courses

2 comments on "Biometric Authentication in Android"

  1. Great article Vasiliy. Just one question. Should we dereference biometricAuthUseCase in onDestroy(), as it is holding the reference to the activity?

    Reply
    • Hi,
      There is no need to dereference this use case because it isn’t referenced by any object with a lifetime longer than the host Activity’s lifetime. Thus, both the host Activity and the use case will become eligible to GC at the same time.

      Reply

Leave a Comment