Mobile Authentication with Biometrics: Complete Implementation Guide

Biometric mobile authentication has become the expected way to secure mobile apps. Users unlock their phones with Face ID or fingerprint dozens of times daily—they expect the same convenience for mobile login in your app. But implementing biometric mobile authentication correctly requires understanding the security model, handling edge cases gracefully, and providing sensible fallbacks.

This guide covers production-ready biometric mobile authentication for both iOS development and Android development, including the nuanced decisions around when to prompt for biometrics, how to handle failures, and how to integrate with your OAuth mobile authentication flow.

Understanding the Biometric Security Model

Biometric authentication on mobile devices doesn’t give your app access to fingerprints or face data. Instead, the operating system provides a secure yes/no answer: “Did the device owner authenticate?” This is a crucial distinction for security and privacy.

What biometrics verify: The person using the device is the device owner (or someone enrolled in biometrics).

What biometrics don’t verify: The person’s identity, that they’re the legitimate user of your app, or that they have valid credentials.

This means biometrics should augment, not replace, your authentication system. The typical pattern:

  1. User logs in with credentials (email/password, OAuth, etc.)
  2. After successful login, offer to enable biometrics for future sessions
  3. Store an authentication token securely (Keychain/Keystore) with biometric protection
  4. On subsequent launches, retrieve the token using biometrics
First Login:
┌───────┐    Credentials    ┌──────────┐    Validate    ┌──────────┐
│ User  │ ───────────────▶ │   App    │ ────────────▶ │  Server  │
└───────┘                   └──────────┘               └──────────┘

                                  │ Store token with biometric protection

                            ┌──────────────┐
                            │  Keychain/   │
                            │  Keystore    │
                            └──────────────┘

Subsequent Launches:
┌───────┐    Biometric     ┌──────────────┐    Token    ┌──────────┐
│ User  │ ───────────────▶ │  Keychain/   │ ─────────▶ │   App    │
└───────┘                   │  Keystore    │            └──────────┘
                            └──────────────┘

iOS Implementation with

iOS Implementation with LocalAuthentication Infographic LocalAuthentication

iOS provides two levels of biometric integration: LocalAuthentication for simple yes/no verification, and Keychain with biometric access control for securing data.

Basic Biometric Prompt

import LocalAuthentication

class BiometricAuthManager {
    enum BiometricType {
        case none
        case touchID
        case faceID
        case opticID // Vision Pro
    }

    func getBiometricType() -> BiometricType {
        let context = LAContext()
        var error: NSError?

        guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else {
            return .none
        }

        switch context.biometryType {
        case .touchID:
            return .touchID
        case .faceID:
            return .faceID
        case .opticID:
            return .opticID
        case .none:
            return .none
        @unknown default:
            return .none
        }
    }

    func authenticate(reason: String) async throws -> Bool {
        let context = LAContext()
        context.localizedCancelTitle = "Use Password"

        var error: NSError?
        guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else {
            throw BiometricError.notAvailable(error?.localizedDescription ?? "Biometrics not available")
        }

        do {
            let success = try await context.evaluatePolicy(
                .deviceOwnerAuthenticationWithBiometrics,
                localizedReason: reason
            )
            return success
        } catch let error as LAError {
            throw mapLAError(error)
        }
    }

    private func mapLAError(_ error: LAError) -> BiometricError {
        switch error.code {
        case .authenticationFailed:
            return .authenticationFailed
        case .userCancel:
            return .userCancelled
        case .userFallback:
            return .userRequestedFallback
        case .biometryNotAvailable:
            return .notAvailable("Biometrics not available")
        case .biometryNotEnrolled:
            return .notEnrolled
        case .biometryLockout:
            return .lockedOut
        default:
            return .unknown(error.localizedDescription)
        }
    }
}

enum BiometricError: Error {
    case notAvailable(String)
    case notEnrolled
    case authenticationFailed
    case userCancelled
    case userRequestedFallback
    case lockedOut
    case unknown(String)
}

Securing Tokens with Keychain and Biometrics

For production apps, combine Keychain storage with biometric access control. This ensures tokens can only be retrieved after successful biometric authentication.

import Security

class SecureTokenStorage {
    private let service = "com.yourapp.auth"
    private let tokenKey = "authToken"

    func saveToken(_ token: String, requireBiometrics: Bool) throws {
        // Create access control with biometric requirement
        var accessControl: SecAccessControl?

        if requireBiometrics {
            var error: Unmanaged<CFError>?
            accessControl = SecAccessControlCreateWithFlags(
                nil,
                kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
                .biometryCurrentSet, // Invalidates if biometrics change
                &error
            )

            if let error = error?.takeRetainedValue() {
                throw TokenError.accessControlCreationFailed(error.localizedDescription)
            }
        }

        // Delete any existing token
        let deleteQuery: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: tokenKey
        ]
        SecItemDelete(deleteQuery as CFDictionary)

        // Save new token
        var addQuery: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: tokenKey,
            kSecValueData as String: token.data(using: .utf8)!
        ]

        if let accessControl = accessControl {
            addQuery[kSecAttrAccessControl as String] = accessControl
        }

        let status = SecItemAdd(addQuery as CFDictionary, nil)

        guard status == errSecSuccess else {
            throw TokenError.saveFailed(status)
        }
    }

    func retrieveToken() async throws -> String {
        let context = LAContext()
        context.localizedReason = "Access your account"

        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: tokenKey,
            kSecReturnData as String: true,
            kSecUseAuthenticationContext as String: context
        ]

        var result: AnyObject?
        let status = SecItemCopyMatching(query as CFDictionary, &result)

        guard status == errSecSuccess,
              let data = result as? Data,
              let token = String(data: data, encoding: .utf8) else {
            throw TokenError.retrievalFailed(status)
        }

        return token
    }

    func deleteToken() {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: tokenKey
        ]
        SecItemDelete(query as CFDictionary)
    }
}

enum TokenError: Error {
    case accessControlCreationFailed(String)
    case saveFailed(OSStatus)
    case retrievalFailed(OSStatus)
}

An

Android Implementation with BiometricPrompt Infographic droid Implementation with BiometricPrompt

Android’s BiometricPrompt API provides a unified interface for fingerprint, face, and iris authentication. Like iOS, the strongest security comes from combining biometrics with the Android Keystore.

Basic Biometric Prompt

import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity

class BiometricAuthManager(private val activity: FragmentActivity) {

    sealed class BiometricResult {
        data object Success : BiometricResult()
        data object Cancelled : BiometricResult()
        data class Error(val code: Int, val message: String) : BiometricResult()
    }

    fun canAuthenticate(): Boolean {
        val biometricManager = BiometricManager.from(activity)
        return biometricManager.canAuthenticate(
            BiometricManager.Authenticators.BIOMETRIC_STRONG
        ) == BiometricManager.BIOMETRIC_SUCCESS
    }

    fun getBiometricStatus(): BiometricStatus {
        val biometricManager = BiometricManager.from(activity)
        return when (biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG)) {
            BiometricManager.BIOMETRIC_SUCCESS -> BiometricStatus.Available
            BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> BiometricStatus.NoHardware
            BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE -> BiometricStatus.HardwareUnavailable
            BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> BiometricStatus.NotEnrolled
            else -> BiometricStatus.Unknown
        }
    }

    suspend fun authenticate(
        title: String,
        subtitle: String,
        negativeButtonText: String = "Cancel"
    ): BiometricResult = suspendCancellableCoroutine { continuation ->
        val executor = ContextCompat.getMainExecutor(activity)

        val callback = object : BiometricPrompt.AuthenticationCallback() {
            override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
                continuation.resume(BiometricResult.Success)
            }

            override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
                if (errorCode == BiometricPrompt.ERROR_USER_CANCELED ||
                    errorCode == BiometricPrompt.ERROR_NEGATIVE_BUTTON) {
                    continuation.resume(BiometricResult.Cancelled)
                } else {
                    continuation.resume(BiometricResult.Error(errorCode, errString.toString()))
                }
            }

            override fun onAuthenticationFailed() {
                // This is called on each failed attempt, but authentication continues
                // Don't resume here - wait for success or error
            }
        }

        val biometricPrompt = BiometricPrompt(activity, executor, callback)

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

        biometricPrompt.authenticate(promptInfo)

        continuation.invokeOnCancellation {
            biometricPrompt.cancelAuthentication()
        }
    }
}

enum class BiometricStatus {
    Available,
    NoHardware,
    HardwareUnavailable,
    NotEnrolled,
    Unknown
}

Securing Tokens with Keystore and Biometrics

import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec

class SecureTokenStorage(private val context: Context) {
    private val keyAlias = "auth_token_key"
    private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
    private val prefs = context.getSharedPreferences("secure_storage", Context.MODE_PRIVATE)

    fun saveToken(token: String, requireBiometrics: Boolean) {
        // Generate or retrieve the encryption key
        val key = getOrCreateKey(requireBiometrics)

        // Encrypt the token
        val cipher = Cipher.getInstance("AES/GCM/NoPadding")
        cipher.init(Cipher.ENCRYPT_MODE, key)

        val encryptedBytes = cipher.doFinal(token.toByteArray(Charsets.UTF_8))
        val iv = cipher.iv

        // Store encrypted data and IV
        prefs.edit()
            .putString("encrypted_token", Base64.encodeToString(encryptedBytes, Base64.DEFAULT))
            .putString("token_iv", Base64.encodeToString(iv, Base64.DEFAULT))
            .apply()
    }

    suspend fun retrieveToken(activity: FragmentActivity): String? {
        val encryptedToken = prefs.getString("encrypted_token", null) ?: return null
        val iv = prefs.getString("token_iv", null) ?: return null

        val key = keyStore.getKey(keyAlias, null) as? SecretKey ?: return null

        val cipher = Cipher.getInstance("AES/GCM/NoPadding")
        val spec = GCMParameterSpec(128, Base64.decode(iv, Base64.DEFAULT))
        cipher.init(Cipher.DECRYPT_MODE, key, spec)

        // For biometric-protected keys, we need to authenticate
        val biometricManager = BiometricAuthManager(activity)

        return suspendCancellableCoroutine { continuation ->
            val executor = ContextCompat.getMainExecutor(activity)

            val callback = object : BiometricPrompt.AuthenticationCallback() {
                override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
                    try {
                        val decryptedCipher = result.cryptoObject?.cipher ?: cipher
                        val decryptedBytes = decryptedCipher.doFinal(
                            Base64.decode(encryptedToken, Base64.DEFAULT)
                        )
                        continuation.resume(String(decryptedBytes, Charsets.UTF_8))
                    } catch (e: Exception) {
                        continuation.resume(null)
                    }
                }

                override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
                    continuation.resume(null)
                }

                override fun onAuthenticationFailed() {
                    // Authentication continues
                }
            }

            val biometricPrompt = BiometricPrompt(activity, executor, callback)

            val promptInfo = BiometricPrompt.PromptInfo.Builder()
                .setTitle("Authenticate")
                .setSubtitle("Access your account")
                .setNegativeButtonText("Cancel")
                .setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG)
                .build()

            biometricPrompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher))
        }
    }

    private fun getOrCreateKey(requireBiometrics: Boolean): SecretKey {
        keyStore.getKey(keyAlias, null)?.let { return it as SecretKey }

        val keyGenerator = KeyGenerator.getInstance(
            KeyProperties.KEY_ALGORITHM_AES,
            "AndroidKeyStore"
        )

        val builder = KeyGenParameterSpec.Builder(
            keyAlias,
            KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
        )
            .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
            .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
            .setKeySize(256)

        if (requireBiometrics) {
            builder
                .setUserAuthenticationRequired(true)
                .setUserAuthenticationParameters(
                    0, // Require auth for every use
                    KeyProperties.AUTH_BIOMETRIC_STRONG
                )
                .setInvalidatedByBiometricEnrollment(true)
        }

        keyGenerator.init(builder.build())
        return keyGenerator.generateKey()
    }

    fun deleteToken() {
        prefs.edit().clear().apply()
        keyStore.deleteEntry(keyAlias)
    }
}

![Handling Edge Cases and Fallbacks Infographic](/images/mobile-app-biometric-authentication-guide-handling-edge-cases-and-fallbacks.webp)

Handling Edge Cases and Fallbacks

Production biometric implementations must handle numerous edge cases gracefully.

Biometric Enrollment Changes

When users add or remove fingerprints or update Face ID, previously stored keys may be invalidated. Handle this gracefully.

// iOS: Check if biometrics changed since token storage
func checkBiometricIntegrity() -> Bool {
    let context = LAContext()
    var error: NSError?

    guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else {
        return false
    }

    // Compare current biometric data with stored domain state
    let currentDomainState = context.evaluatedPolicyDomainState
    let storedDomainState = UserDefaults.standard.data(forKey: "biometricDomainState")

    if let stored = storedDomainState, stored != currentDomainState {
        // Biometrics changed - require re-authentication
        return false
    }

    // Store current state for future comparison
    if let current = currentDomainState {
        UserDefaults.standard.set(current, forKey: "biometricDomainState")
    }

    return true
}

Lockout Handling

After too many failed attempts, biometrics may be locked. Provide clear guidance to users.

when (val result = biometricManager.authenticate("Unlock App", "Verify your identity")) {
    is BiometricResult.Success -> {
        // Proceed with authentication
    }
    is BiometricResult.Cancelled -> {
        // Show password option
        showPasswordLogin()
    }
    is BiometricResult.Error -> {
        when (result.code) {
            BiometricPrompt.ERROR_LOCKOUT -> {
                // Too many attempts - temporary lockout
                showMessage("Too many failed attempts. Please wait and try again, or use your password.")
                showPasswordLogin()
            }
            BiometricPrompt.ERROR_LOCKOUT_PERMANENT -> {
                // Permanent lockout until device credentials used
                showMessage("Biometrics locked. Please unlock your device first, then try again.")
                // Optionally prompt for device credentials
                promptForDeviceCredentials()
            }
            else -> {
                showMessage("Authentication error: ${result.message}")
                showPasswordLogin()
            }
        }
    }
}

UX Best Practices

Don’t auto-prompt on every launch: Give users a moment to orient themselves. A brief delay (300-500ms) before showing the biometric prompt feels more natural.

Provide clear fallback paths: Always offer password/PIN login as an alternative. Some users prefer not to use biometrics.

Explain the value: When asking users to enable biometrics, explain the benefit: “Enable Face ID to sign in instantly next time.”

// SwiftUI biometric prompt with delay
struct LoginView: View {
    @StateObject private var viewModel = LoginViewModel()
    @State private var showBiometricPrompt = false

    var body: some View {
        VStack {
            // Login UI...

            if viewModel.biometricsAvailable {
                Button("Use \(viewModel.biometricTypeName)") {
                    Task {
                        await viewModel.authenticateWithBiometrics()
                    }
                }
            }
        }
        .onAppear {
            // Delay biometric prompt slightly for better UX
            if viewModel.shouldAutoPromptBiometrics {
                DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
                    Task {
                        await viewModel.authenticateWithBiometrics()
                    }
                }
            }
        }
    }
}

Conclusion

Biometric mobile authentication significantly improves user experience when implemented correctly. The key points to remember for secure mobile login:

  1. Biometrics augment, not replace your mobile authentication system. Store OAuth mobile tokens securely with biometric protection.

  2. Use platform Keychain/Keystore with biometric access control for the strongest mobile authentication security model in iOS development and Android development.

  3. Handle edge cases: enrollment changes, lockouts, and device restrictions all need graceful handling for robust mobile login.

  4. Always provide fallbacks: Not all users can or want to use biometric mobile authentication.

  5. Respect user choice: Let users enable/disable biometrics easily, and don’t force the feature in your mobile authentication flow.

The implementation investment in biometric mobile authentication pays off in dramatically reduced friction for returning users—and in an era where users expect instant mobile login access, that friction reduction translates directly to engagement and retention.

Enhance your security strategy with our guides on iOS widgets and app extensions and SwiftUI advanced patterns.

Frequently Asked Questions About Mobile Authentication with Biometrics

How does biometric mobile authentication work securely?

Biometric mobile authentication doesn’t give apps access to fingerprints or face data. The OS provides a secure yes/no answer confirming device owner identity. Biometrics should augment OAuth mobile systems by protecting access tokens stored in Keychain (iOS) or Keystore (Android) for secure mobile login.

Should I use biometrics or OAuth for mobile authentication?

Use both - OAuth mobile handles credential validation and token issuance, while biometrics protect token retrieval for subsequent logins. First login uses OAuth mobile credentials, then biometrics unlock stored tokens for future sessions, providing best-in-class mobile authentication security.

What happens when users change their biometrics?

When users add/remove fingerprints or update Face ID, previously stored keys may be invalidated. Implement domain state checking (iOS development) or set setInvalidatedByBiometricEnrollment(true) (Android development) to detect changes and require re-authentication, maintaining mobile login security.

How do I handle biometric lockouts in mobile authentication?

After too many failed biometric attempts, mobile authentication may be temporarily or permanently locked. Provide clear messaging and immediate fallback to password/PIN mobile login. For permanent lockouts (Android), prompt users to unlock their device first before retrying mobile authentication.

What are biometric authentication best practices for mobile login?

Best practices include: don’t auto-prompt immediately on launch (300-500ms delay), always offer password/PIN fallback for mobile login, explain biometric value before asking for permission, store tokens with biometric protection in secure storage, and handle all error states gracefully in your mobile authentication flow.

Essential Mobile Authentication Insights

Biometric mobile authentication on iOS and Android provides zero-friction login while maintaining security - Face ID and fingerprint unlock take under 1 second versus 10+ seconds for password entry.

Keychain (iOS) and Keystore (Android) with biometric access control ensure authentication tokens can only be retrieved after successful biometric verification - preventing token theft even if device storage is compromised.

Implementing proper fallback flows in mobile authentication is critical - approximately 5-10% of users cannot or choose not to use biometrics, requiring password/PIN mobile login alternatives.


Need help implementing secure authentication in your mobile app? The Awesome Apps team specializes in building secure, user-friendly apps for Australian businesses with robust mobile authentication. Get in touch to discuss your project.