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:
- User logs in with credentials (email/password, OAuth, etc.)
- After successful login, offer to enable biometrics for future sessions
- Store an authentication token securely (Keychain/Keystore) with biometric protection
- 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
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
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
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:
-
Biometrics augment, not replace your mobile authentication system. Store OAuth mobile tokens securely with biometric protection.
-
Use platform Keychain/Keystore with biometric access control for the strongest mobile authentication security model in iOS development and Android development.
-
Handle edge cases: enrollment changes, lockouts, and device restrictions all need graceful handling for robust mobile login.
-
Always provide fallbacks: Not all users can or want to use biometric mobile authentication.
-
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.