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.
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:
-
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.
-
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.
-
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:
- Primary: Biometric. The default, fastest authentication method.
- 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 isAuthenticators.DEVICE_CREDENTIAL. - 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.
- 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
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.
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.
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.