Biometric Authentication in Fintech: Beyond Face ID

A deep technical exploration of biometric authentication in mobile banking, covering implementation on iOS and Android, fallback strategies, liveness detection, and the security considerations unique to financial applications.

technical10 min readBy Klivvr Engineering
Share:

Biometric authentication has become the front door of mobile banking. When a user opens the Klivvr app, they expect to see their balance within a second — authenticated by a glance at their phone or a touch of their finger. The days of typing a six-digit PIN every time are over. But behind that seamless one-second experience lies a layered authentication system that must balance convenience against the reality that biometrics are probabilistic, not deterministic, and that a compromised biometric cannot be "reset" the way a password can.

This article covers the practical implementation of biometric authentication on iOS and Android, the security architecture that surrounds it, and the edge cases that fintech teams must handle carefully.

How Biometric Authentication Actually Works on Mobile

A common misconception is that biometric authentication sends your fingerprint or face data to the server. It does not. On both iOS and Android, biometric authentication is a local operation that unlocks access to a cryptographic key stored in secure hardware. The flow is:

  1. During enrollment, the app generates a key pair inside the device's secure element (Secure Enclave on iOS, StrongBox/TEE on Android). The private key never leaves the secure hardware. The public key is sent to the server.

  2. When the user authenticates with a biometric, the OS verifies the biometric locally. If it matches, the OS grants the app temporary access to the private key.

  3. The app uses the private key to sign a challenge from the server (or to decrypt a stored token). The server verifies the signature using the public key.

This means biometric authentication is really key-based authentication with a biometric unlock. The biometric itself is never transmitted, stored by the app, or accessible to the developer.

iOS Implementation with LocalAuthentication and Keychain

On iOS, biometric authentication integrates with the Keychain through access control policies. Here is how we implement it at Klivvr:

// BiometricAuthManager.swift
import LocalAuthentication
import Security
 
class BiometricAuthManager {
 
    enum BiometricError: Error {
        case notAvailable
        case notEnrolled
        case lockout
        case cancelled
        case failed(Error)
    }
 
    // Check biometric availability before prompting
    func checkBiometricAvailability() -> Result<LABiometryType, BiometricError> {
        let context = LAContext()
        var error: NSError?
 
        guard context.canEvaluatePolicy(
            .deviceOwnerAuthenticationWithBiometrics,
            error: &error
        ) else {
            if let laError = error as? LAError {
                switch laError.code {
                case .biometryNotAvailable:
                    return .failure(.notAvailable)
                case .biometryNotEnrolled:
                    return .failure(.notEnrolled)
                case .biometryLockout:
                    return .failure(.lockout)
                default:
                    return .failure(.failed(laError))
                }
            }
            return .failure(.notAvailable)
        }
 
        return .success(context.biometryType)
    }
 
    // Store authentication token protected by biometric
    func storeTokenWithBiometricProtection(
        token: String,
        account: String
    ) throws {
        // Create access control requiring biometric authentication
        guard let accessControl = SecAccessControlCreateWithFlags(
            nil,
            kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
            [.biometryCurrentSet, .privateKeyUsage],
            nil
        ) else {
            throw BiometricError.notAvailable
        }
 
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: account,
            kSecAttrService as String: "com.klivvr.auth",
            kSecValueData as String: token.data(using: .utf8)!,
            kSecAttrAccessControl as String: accessControl
        ]
 
        // Delete any existing item first
        SecItemDelete(query as CFDictionary)
 
        let status = SecItemAdd(query as CFDictionary, nil)
        guard status == errSecSuccess else {
            throw BiometricError.failed(
                NSError(domain: NSOSStatusErrorDomain, code: Int(status))
            )
        }
    }
 
    // Retrieve token — this triggers the biometric prompt
    func authenticateAndRetrieveToken(
        account: String,
        reason: String
    ) async throws -> String {
        let context = LAContext()
        context.localizedReason = reason
        context.localizedCancelTitle = "Use PIN Instead"
 
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: account,
            kSecAttrService as String: "com.klivvr.auth",
            kSecReturnData as String: true,
            kSecUseAuthenticationContext as String: context
        ]
 
        return try await withCheckedThrowingContinuation { continuation in
            DispatchQueue.global().async {
                var result: AnyObject?
                let status = SecItemCopyMatching(query as CFDictionary, &result)
 
                if status == errSecSuccess,
                   let data = result as? Data,
                   let token = String(data: data, encoding: .utf8) {
                    continuation.resume(returning: token)
                } else {
                    continuation.resume(throwing: BiometricError.failed(
                        NSError(domain: NSOSStatusErrorDomain, code: Int(status))
                    ))
                }
            }
        }
    }
}

A critical detail is the .biometryCurrentSet flag in the access control. This flag invalidates the stored item if the user adds or removes a biometric (e.g., enrolls a new fingerprint). Without this flag, someone who gains physical access to the device could enroll their own fingerprint and then authenticate as the account holder. With .biometryCurrentSet, any change to the enrolled biometrics invalidates the stored token, forcing re-authentication with the primary credential (PIN or password).

Android Implementation with BiometricPrompt and Keystore

On Android, the BiometricPrompt API (from the androidx.biometric library) provides a unified interface across fingerprint, face, and iris sensors:

// BiometricAuthManager.kt
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
 
class BiometricAuthManager(private val activity: FragmentActivity) {
 
    companion object {
        private const val KEY_ALIAS = "klivvr_biometric_key"
        private const val KEYSTORE_PROVIDER = "AndroidKeyStore"
    }
 
    fun checkBiometricAvailability(): BiometricAvailability {
        val manager = BiometricManager.from(activity)
        return when (manager.canAuthenticate(
            BiometricManager.Authenticators.BIOMETRIC_STRONG
        )) {
            BiometricManager.BIOMETRIC_SUCCESS ->
                BiometricAvailability.Available
            BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE ->
                BiometricAvailability.NoHardware
            BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE ->
                BiometricAvailability.HardwareUnavailable
            BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED ->
                BiometricAvailability.NotEnrolled
            else ->
                BiometricAvailability.Unknown
        }
    }
 
    fun generateBiometricKey() {
        val keyGenerator = KeyGenerator.getInstance(
            KeyProperties.KEY_ALGORITHM_AES,
            KEYSTORE_PROVIDER
        )
 
        val spec = KeyGenParameterSpec.Builder(
            KEY_ALIAS,
            KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
        )
            .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
            .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
            .setUserAuthenticationRequired(true)
            .setInvalidatedByBiometricEnrollment(true)  // Critical for security
            .setUserAuthenticationParameters(
                0, // Require authentication for every use
                KeyProperties.AUTH_BIOMETRIC_STRONG
            )
            .build()
 
        keyGenerator.init(spec)
        keyGenerator.generateKey()
    }
 
    fun authenticateAndDecrypt(
        encryptedData: ByteArray,
        iv: ByteArray,
        onSuccess: (String) -> Unit,
        onError: (String) -> Unit,
        onFallback: () -> Unit
    ) {
        val key = getSecretKey() ?: run {
            onError("Biometric key not found. Please set up biometrics again.")
            return
        }
 
        val cipher = Cipher.getInstance("AES/GCM/NoPadding")
        try {
            cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(128, iv))
        } catch (e: KeyPermanentlyInvalidatedException) {
            // Biometric enrollment changed — force re-authentication
            onError("Your biometric settings changed. Please sign in with your PIN.")
            return
        }
 
        val cryptoObject = BiometricPrompt.CryptoObject(cipher)
 
        val promptInfo = BiometricPrompt.PromptInfo.Builder()
            .setTitle("Sign in to Klivvr")
            .setSubtitle("Use your fingerprint or face to access your account")
            .setNegativeButtonText("Use PIN instead")
            .setAllowedAuthenticators(
                BiometricManager.Authenticators.BIOMETRIC_STRONG
            )
            .build()
 
        val biometricPrompt = BiometricPrompt(
            activity,
            ContextCompat.getMainExecutor(activity),
            object : BiometricPrompt.AuthenticationCallback() {
                override fun onAuthenticationSucceeded(
                    result: BiometricPrompt.AuthenticationResult
                ) {
                    val decryptedCipher = result.cryptoObject?.cipher ?: return
                    val decryptedBytes = decryptedCipher.doFinal(encryptedData)
                    onSuccess(String(decryptedBytes))
                }
 
                override fun onAuthenticationError(
                    errorCode: Int, errString: CharSequence
                ) {
                    if (errorCode == BiometricPrompt.ERROR_NEGATIVE_BUTTON) {
                        onFallback()
                    } else {
                        onError(errString.toString())
                    }
                }
 
                override fun onAuthenticationFailed() {
                    // Individual attempt failed, but the prompt remains visible
                    // for retry. No action needed here.
                }
            }
        )
 
        biometricPrompt.authenticate(promptInfo, cryptoObject)
    }
 
    private fun getSecretKey(): SecretKey? {
        val keyStore = KeyStore.getInstance(KEYSTORE_PROVIDER)
        keyStore.load(null)
        return keyStore.getKey(KEY_ALIAS, null) as? SecretKey
    }
}

The setInvalidatedByBiometricEnrollment(true) parameter mirrors iOS's .biometryCurrentSet behavior. When the user enrolls a new fingerprint, the key becomes permanently invalid, and the KeyPermanentlyInvalidatedException is thrown. The app must handle this gracefully by requiring the user to re-authenticate with their primary credential and re-enroll biometrics.

Fallback Authentication Strategy

Biometric authentication will fail. Fingers get wet, faces get obscured, sensors malfunction. A robust authentication system needs a clear fallback hierarchy:

  1. Primary: Biometric. The default, fastest authentication method.
  2. Secondary: Device PIN/passcode. If biometric fails three times, offer the device passcode as a fallback. On iOS, this is .deviceOwnerAuthentication (which allows passcode); on Android, this is Authenticators.DEVICE_CREDENTIAL.
  3. Tertiary: App-specific PIN. If the user prefers not to use device-level authentication, the app provides its own PIN entry. This PIN is hashed and stored securely, independent of the device passcode.
  4. Last resort: Full re-authentication. If the app-specific PIN fails multiple times, or if the biometric key has been invalidated, the user must authenticate with their full credentials (email/phone + password or OTP).
// AuthenticationCoordinator.swift
class AuthenticationCoordinator {
    private let biometricManager: BiometricAuthManager
    private let pinManager: PINAuthManager
    private let fullAuthManager: FullAuthManager
 
    func authenticate() async -> AuthenticationResult {
        // Try biometric first
        switch biometricManager.checkBiometricAvailability() {
        case .success:
            do {
                let token = try await biometricManager.authenticateAndRetrieveToken(
                    account: "auth_token",
                    reason: "Sign in to Klivvr"
                )
                return .success(token: token)
            } catch BiometricAuthManager.BiometricError.cancelled {
                // User tapped "Use PIN Instead"
                return await authenticateWithPIN()
            } catch {
                return await authenticateWithPIN()
            }
 
        case .failure(.lockout):
            // Biometric is locked out — go straight to PIN
            return await authenticateWithPIN()
 
        case .failure(.notEnrolled), .failure(.notAvailable):
            // No biometric available — go straight to PIN
            return await authenticateWithPIN()
 
        default:
            return await authenticateWithPIN()
        }
    }
 
    private func authenticateWithPIN() async -> AuthenticationResult {
        // Show PIN entry UI
        let result = await pinManager.authenticate()
        switch result {
        case .success(let token):
            return .success(token: token)
        case .maxAttemptsReached:
            return await authenticateWithFullCredentials()
        case .cancelled:
            return .cancelled
        }
    }
 
    private func authenticateWithFullCredentials() async -> AuthenticationResult {
        return await fullAuthManager.authenticate()
    }
}

Step-Up Authentication for High-Risk Actions

Not all actions in a banking app require the same level of authentication assurance. Viewing a balance requires a baseline authentication (biometric at app launch). Sending money to a new recipient requires step-up authentication — a fresh biometric check at the moment of the action, not just the cached session from app launch.

// StepUpAuthenticator.kt
class StepUpAuthenticator(
    private val biometricAuth: BiometricAuthManager,
    private val sessionManager: SessionManager
) {
    fun requireStepUp(
        action: HighRiskAction,
        onAuthenticated: () -> Unit,
        onDenied: () -> Unit
    ) {
        val lastAuthTimestamp = sessionManager.getLastBiometricTimestamp()
        val maxAge = when (action) {
            HighRiskAction.TRANSFER_NEW_RECIPIENT -> Duration.ZERO  // Always require fresh
            HighRiskAction.CHANGE_PIN -> Duration.ZERO
            HighRiskAction.TRANSFER_EXISTING_RECIPIENT -> Duration.ofMinutes(5)
            HighRiskAction.VIEW_CARD_DETAILS -> Duration.ofMinutes(2)
            HighRiskAction.EXPORT_STATEMENT -> Duration.ofMinutes(5)
        }
 
        val timeSinceLastAuth = Duration.between(lastAuthTimestamp, Instant.now())
 
        if (timeSinceLastAuth <= maxAge) {
            // Recent authentication is still valid
            onAuthenticated()
        } else {
            biometricAuth.authenticateAndDecrypt(
                encryptedData = sessionManager.getEncryptedSessionKey(),
                iv = sessionManager.getSessionIV(),
                onSuccess = { _ ->
                    sessionManager.updateBiometricTimestamp()
                    onAuthenticated()
                },
                onError = { onDenied() },
                onFallback = { onDenied() }
            )
        }
    }
}

This pattern ensures that even if an attacker gains access to an unlocked, authenticated app (e.g., the user left it open on a table), high-risk actions still require a fresh biometric verification.

Anti-Spoofing and Liveness Considerations

Device-level biometric authentication (Face ID, Android BiometricPrompt with BIOMETRIC_STRONG) includes built-in anti-spoofing measures. Face ID uses an infrared depth map; Android's BIOMETRIC_STRONG class requires hardware-backed sensor modules with tested false acceptance rates. For most banking use cases, the platform-level protections are sufficient.

However, for higher-assurance scenarios — such as identity verification during onboarding or authenticating a very large transfer — you may want application-level liveness detection. This typically involves a third-party SDK that captures a short video of the user's face, analyzes it for signs of a presentation attack (photo, mask, deepfake), and returns a liveness score.

We integrate liveness detection as an additional step-up layer, not as a replacement for platform biometrics:

func performHighAssuranceAuthentication() async throws -> AuthResult {
    // Step 1: Standard biometric authentication
    let token = try await biometricManager.authenticateAndRetrieveToken(
        account: "auth_token",
        reason: "Verify your identity for this transfer"
    )
 
    // Step 2: Liveness check for transfers above threshold
    let livenessResult = try await livenessDetector.performCheck()
    guard livenessResult.score > 0.95 else {
        throw AuthError.livenessCheckFailed
    }
 
    return AuthResult(token: token, livenessScore: livenessResult.score)
}

Conclusion

Biometric authentication in a fintech app is not a single feature — it is a system. It spans secure key generation, platform-specific API integration, fallback hierarchies, step-up authentication for high-risk actions, and anti-spoofing measures. The user sees a one-second face scan; behind it lies a chain of cryptographic operations, security policies, and edge-case handling that took months to design correctly. The key principle is to treat biometrics as a convenience layer over strong cryptographic authentication, never as the sole authentication factor. The biometric unlocks the key; the key proves the identity. This separation is what makes the system both usable and secure.

Related Articles

business

Reducing Friction in Fintech User Onboarding

Strategies and technical approaches for streamlining fintech user onboarding, from identity verification and KYC to progressive profiling, while balancing regulatory compliance with conversion optimization.

11 min read
business

Layered Security Architecture for Mobile Banking

An in-depth look at the multi-layered security architecture that protects mobile banking apps, from device integrity checks and encrypted storage to runtime protection and network security.

9 min read
business

App Store Optimization for Fintech Products

A comprehensive guide to App Store Optimization (ASO) for fintech mobile apps, covering keyword strategy, creative optimization, ratings management, and the unique challenges of marketing a financial product in competitive app stores.

9 min read