Skip to content
ExponaBLOG
MODERN ANDROID & KOTLIN · DAY 056 / 100

Biometric authentication for Android & Kotlin developers

Fingerprint and face unlock.

Expona Admin7 min read

Biometric authentication for Android & Kotlin developers

Your users unlock their phones dozens of times a day with their fingerprint or face. That same trusted mechanism can protect sensitive features in your app—but only if you implement it correctly. We'll walk through the modern BiometricPrompt API, understand when to fall back to device credentials, and learn how to use biometric authentication as a gate to unlock secrets stored in Android Keystore, while keeping in mind that biometrics enhance user experience, not replace server-side authentication.

Why biometric authentication matters in production

In e-commerce apps, payment flows, and CRM systems, users need fast access without sacrificing security. A fingerprint scan takes one second; typing a password takes twenty. Yet many teams skip biometric support because they're unsure how to integrate it or worry about edge cases like devices without sensors, or users who haven't enrolled.

The BiometricPrompt API, available since Android 10 (and backported to API 28 via AndroidX), abstracts away these complications. It handles sensor availability, fallback flows, and user enrollment checks. When you use it properly, you give users a seamless unlock experience while keeping sensitive data protected.

Understanding BiometricPrompt API

BiometricPrompt is the modern, unified interface for fingerprint, face, and iris authentication. It replaced the deprecated FingerprintManager and handles the UI, sensor communication, and error states for you.

Here's a basic setup in a real login or payment confirmation scenario:

kotlin
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat
import android.content.Context

class BiometricAuthManager(private val context: Context) {
    fun canUseBiometric(): Boolean {
        val biometricManager = BiometricManager.from(context)
        return biometricManager.canAuthenticate(
            BiometricManager.Authenticators.BIOMETRIC_STRONG
        ) == BiometricManager.BIOMETRIC_SUCCESS
    }

    fun showBiometricPrompt(
        activity: androidx.appcompat.app.AppCompatActivity,
        onSuccess: () -> Unit,
        onError: (String) -> Unit
    ) {
        val executor = ContextCompat.getMainExecutor(context)
        val biometricPrompt = BiometricPrompt(
            activity,
            executor,
            object : BiometricPrompt.AuthenticationCallback() {
                override fun onAuthenticationSucceeded(
                    result: BiometricPrompt.AuthenticationResult
                ) {
                    super.onAuthenticationSucceeded(result)
                    onSuccess()
                }

                override fun onAuthenticationError(
                    errorCode: Int,
                    errString: CharSequence
                ) {
                    super.onAuthenticationError(errorCode, errString)
                    onError(errString.toString())
                }

                override fun onAuthenticationFailed() {
                    super.onAuthenticationFailed()
                    onError("Authentication failed. Try again.")
                }
            }
        )

        val promptInfo = BiometricPrompt.PromptInfo.Builder()
            .setTitle("Unlock your account")
            .setSubtitle("Use your fingerprint or face")
            .setNegativeButtonText("Cancel")
            .setAllowedAuthenticators(
                BiometricManager.Authenticators.BIOMETRIC_STRONG
            )
            .build()

        biometricPrompt.authenticate(promptInfo)
    }
}

The canAuthenticate() call checks device capabilities. BIOMETRIC_STRONG means fingerprint or face unlock at the device's highest security level. The callback fires once authentication succeeds or fails—there's no polling.

In a payment flow, you'd call showBiometricPrompt() when the user taps "Confirm Payment." If they succeed, you proceed. If they cancel or fail after retries, you fall back to another method (more on that below).

Fallback to device credentials

Not all devices have biometric sensors. Some users haven't enrolled. Some prefer a PIN. The BiometricPrompt API lets you allow device credentials—PIN, pattern, or password—as a fallback authenticator.

Update your prompt configuration:

kotlin
val promptInfo = BiometricPrompt.PromptInfo.Builder()
    .setTitle("Unlock your account")
    .setSubtitle("Use your fingerprint, face, or device PIN")
    .setAllowedAuthenticators(
        BiometricManager.Authenticators.BIOMETRIC_STRONG or
        BiometricManager.Authenticators.DEVICE_CREDENTIAL
    )
    .build()

Now, if biometric fails or isn't available, the system automatically shows the device unlock screen (PIN/pattern/password). The same onAuthenticationSucceeded() callback fires regardless of which method succeeded.

In production, we often see this pattern in ride-hailing and travel apps, where payment confirmation must be frictionless. A user who lost their enrollment can still verify with their PIN, and the flow doesn't break.

Important: When DEVICE_CREDENTIAL is allowed, do not show a negative button. The system handles cancellation itself. Omit setNegativeButtonText() in that case.

Using biometric authentication to unlock Keystore secrets

Biometric authentication is most powerful when it guards access to sensitive data. Android Keystore is the secure storage for cryptographic keys. You can configure a key to require biometric unlock before it can be used.

Here's a common scenario: a payment app stores an encryption key in Keystore that encrypts the user's credit card token before sending it to the server. Only after successful biometric authentication should that key be available for decryption.

kotlin
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import javax.crypto.KeyGenerator
import javax.crypto.Cipher
import java.security.KeyStore

class SecureKeyManager {
    private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
    private val keyAlias = "payment_encryption_key"

    fun generateBiometricProtectedKey() {
        val keyGenSpec = KeyGenParameterSpec.Builder(
            keyAlias,
            KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
        )
            .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
            .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
            .setUserAuthenticationRequired(true)
            .setUserAuthenticationValidityDurationSeconds(300)
            .build()

        val keyGenerator = KeyGenerator.getInstance(
            KeyProperties.KEY_ALGORITHM_AES,
            "AndroidKeyStore"
        )
        keyGenerator.init(keyGenSpec)
        keyGenerator.generateKey()
    }

    fun getCipherForEncryption(): Cipher? {
        return try {
            val key = keyStore.getKey(keyAlias, null)
            Cipher.getInstance("AES/GCM/NoPadding").apply {
                init(Cipher.ENCRYPT_MODE, key)
            }
        } catch (e: Exception) {
            null
        }
    }
}

The setUserAuthenticationRequired(true) flag tells the system: this key can only be used within 300 seconds of successful biometric or device credential authentication. After that window closes, the user must authenticate again.

In your payment confirmation flow, you'd do this:

kotlin
biometricAuthManager.showBiometricPrompt(
    activity = this,
    onSuccess = {
        val cipher = secureKeyManager.getCipherForEncryption()
        if (cipher != null) {
            val encryptedToken = encryptCardToken(cipher)
            submitPayment(encryptedToken)
        }
    },
    onError = { message ->
        Toast.makeText(this, "Auth failed: $message", Toast.LENGTH_SHORT).show()
    }
)

The Keystore integration ensures that even if an attacker gains filesystem access, they cannot decrypt the key without biometric or PIN authentication. This is critical in fintech and healthcare apps.

Do not replace server-side authentication

A common mistake: treating biometric authentication as the sole security layer. It isn't. Biometric auth on the device proves the user is present and unlocked their phone. It does not prove their identity to your server.

Always pair client-side biometric with server-side authentication:

  • Login flow: User scans fingerprint locally, then sends username + signed request to your backend. The backend verifies the signature and issues a session token.
  • Payment flow: Biometric unlocks the encryption key, user encrypts payment intent, server re-validates all payment details and the user's account status before processing.
  • Sensitive data access: Biometric unlocks local retrieval of cached data, but any modification or high-risk action requires re-authentication with your API.

In a SaaS CRM system we worked on, users could unlock cached customer records with their fingerprint. But creating or updating a customer required a fresh API call with a session token, which the backend validated. This balanced speed (fast local unlock) with security (server-side enforcement).

The architecture looks like:

ActionClient (Biometric)Server
View cached recordBiometric checkNone (trust cache timestamp)
Edit and save recordBiometric check, encryptValidate session, re-auth if needed, update DB
Delete recordBiometric checkValidate session + authorization, delete from DB

Handling edge cases and errors

In production, you'll encounter:

  • Biometric timeout: User takes too long to scan. The prompt auto-closes after 30 seconds. Retry gracefully.
  • Sensor failures: Dirty fingerprint reader, poor lighting. BiometricPrompt retries internally; let the user fallback to PIN if available.
  • Enrollment changes: User enrolled a new fingerprint or removed all. canAuthenticate() now returns BIOMETRIC_ERROR_NO_BIOMETRICS. Switch to device credential or re-enrollment flow.
  • Lockout: Too many failed attempts (usually 5). The system locks biometric for 30 seconds. Inform the user and offer PIN fallback.

Check for these errors in onAuthenticationError():

kotlin
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
    when (errorCode) {
        BiometricPrompt.ERROR_NO_BIOMETRICS -> {
            onError("No fingerprint enrolled. Use your PIN instead.")
        }
        BiometricPrompt.ERROR_NEGATIVE_BUTTON -> {
            onError("Cancelled by user.")
        }
        BiometricPrompt.ERROR_TIMEOUT -> {
            onError("Took too long. Try again.")
        }
        BiometricPrompt.ERROR_LOCKOUT -> {
            onError("Too many attempts. Try again in 30 seconds.")
        }
        else -> onError(errString.toString())
    }
}

Common mistakes

  • Skipping canAuthenticate() check: Calling showBiometricPrompt() without first verifying device support crashes or shows a confusing error. Always check capabilities first.
  • Storing sensitive data in shared preferences as fallback: If biometric fails, developers sometimes store unencrypted tokens in SharedPreferences for quick retry. Never do this. Use Keystore or require server re-auth.
  • Relying on biometric alone for authorization: A fingerprint proves presence, not identity. Always validate on the server.
  • Ignoring the 300-second validity window: If your Keystore key expires after 300 seconds, but your operation takes 400 seconds, it fails mid-operation. Choose validity carefully or re-auth during long operations.
  • Not handling enrollment revocation: If a user removes all biometric enrollment, your app should degrade gracefully, not crash. Implement a re-enrollment or fallback flow.

Key takeaways

  • Use BiometricPrompt from AndroidX for unified fingerprint and face authentication; it abstracts sensor differences and error handling.
  • Always check canAuthenticate() before showing the prompt to handle devices without sensors or users without enrollment.
  • Allow DEVICE_CREDENTIAL as a fallback when biometric isn't available or the user prefers PIN/pattern.
  • Integrate Keystore with biometric by setting setUserAuthenticationRequired(true) on keys; this ensures encryption keys are only usable after authentication.
  • Biometric authentication enhances UX and local security but does not replace server-side authentication or authorization checks.
  • Set appropriate validity windows (e.g., 300 seconds) for Keystore keys based on your operation duration.
  • Handle errors gracefully: timeouts, lockouts, and enrollment changes should offer clear fallback options.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles