brainstorming
brainstorming
You MUST use this before any creative work - creating features, building components, adding functionality, modifying behavior, or planning anything new, in software or out of it (a talk, a business, a renovation).
mobile-biometricsUse this skill when the user says 'biometrics', 'Face ID', 'Touch ID', 'fingerprint', 'fingerprint auth', 'biometric auth', 'face unlock', 'local authentication', 'biometric prompt', 'device credential'. Implement biometric authentication with secure storage on iOS and Android. Do NOT use for: server-side authentication or password-based auth.
Independent GitHub snapshot · Automated classification · Original documentation governs
Guide for implementing biometric authentication (Face ID, Touch ID, fingerprint) with secure storage and fallback UX.
Phrases: "biometrics", "Face ID", "Touch ID", "fingerprint", "fingerprint auth", "biometric auth", "face unlock", "local authentication", "biometric prompt", "device credential"
Biometric auth module: availability check, prompt flow, secure storage with biometric protection, fallback to device credential, UX handling.
No preamble. No postamble. No explanations. No filler/hedging/transitions. Compress output — why use many token when few do trick.
6000 tokens
What operation are you protecting?
├── App unlock (every launch) → Biometric + device credential
│ iOS: LAContext(.deviceOwnerAuthentication)
│ Android: BiometricPrompt with DEVICE_CREDENTIAL
├── High-sensitivity (payments, passwords, personal data)
│ → Class 3 (Strong) biometric required
│ Fallback to device credential
├── Low-sensitivity (dashboard view, quick action)
│ → Class 2 (Weak) biometric acceptable
│ No fallback needed — just require re-auth
└── Data decryption (read stored secrets)
→ Biometric key (Android: setUserAuthenticationRequired, iOS: biometryCurrentSet)
Key invalidated on biometric enrollment change
Which Android biometric class?
├── Class 3 (Strong) → BIOMETRIC_STRONG
│ Pin/pattern + hardware-backed biometric
│ Used for: payments, auth tokens, PII
├── Class 2 (Weak) → BIOMETRIC_WEAK
│ Face recognition, iris — less secure
│ Used for: convenience unlock, non-sensitive
└── Device credential → DEVICE_CREDENTIAL
PIN, pattern, password — always available
Biometric type detection — Three categories of biometric authentication with varying security levels. Class 3 (Strong): Face ID (iPhone X+), Touch ID (iPhone 5s+), Pixel Imprint, ultrasonic fingerprint. Class 2 (Weak): face unlock on budget Android devices, iris scanner. Class 1: convenience face unlock. Android: Check with BiometricManager.canAuthenticate(BIOMETRIC_STRONG) vs BIOMETRIC_WEAK. iOS: LAContext.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics) — Face ID on devices with TrueDepth camera, Touch ID on devices with Touch ID sensor. Device credential (PIN, pattern, password) is always available as fallback and is considered a biometric alternative on iOS (deviceOwnerAuthentication), but not equivalent to biometric on Android (DEVICE_CREDENTIAL flag).
Biometric availability check — Before showing a biometric prompt, check: (a) Is biometric hardware available? (hardware present). (b) Is biometric enrolled? (user has registered at least one fingerprint/face). (c) Is device credential configured? (PIN/password set — required fallback). (d) Are there any transient issues? (security update required, sensor dirty, too many attempts — lockout). Handle each failure case with a specific user-facing message. On Android, BiometricManager returns distinct error codes. On iOS, LAContext.canEvaluatePolicy returns NSError with LAError codes. Never show biometric prompt without first checking availability.
Authentication prompt flow — Flow: user triggers protected action -> check availability -> if biometric available -> show biometric prompt with reason string -> user authenticates -> on success -> grant access -> on failure -> allow retry (up to 5 attempts) -> on 5th failure -> lockout -> force device credential. If biometric not available at start -> fall through to device credential. The reason string is mandatory on both platforms and displayed prominently. On iOS, localizedReason is shown in the Face ID dialog. On Android, setTitle and setSubtitle are shown in the system prompt. After successful authentication, the app receives a callback with (optionally) a CryptoObject for decryption.
Secure storage with biometric protection — Store sensitive data (auth tokens, encryption keys, passwords) in platform secure stores with biometric access control. Android: EncryptedSharedPreferences backed by Android Keystore — set setUserAuthenticationRequired(true) on the master key to require biometric before reading. Or use Keystore SecretKey with setUserAuthenticationRequired(true) and encrypt/decrypt data through CryptoObject. setInvalidatedByBiometricEnrollment(true) ensures key is destroyed if new biometric is enrolled (prevents stolen biometric from accessing old data). iOS: Keychain with SecAccessControlCreateWithFlags using biometryCurrentSet — item is only accessible after biometric authentication and is invalidated on biometric enrollment change. For "cache after auth" pattern, use setUserAuthenticationValidityDurationSeconds (Android) or kSecUseAuthenticationUIFallback (iOS).
Fallback and UX design — Always provide device credential (PIN/password) as fallback — never block users out. Three-tier fallback chain: Biometric -> Device Credential -> App Password (optional, for users who can't use either). UX patterns: opt-in onboarding screen explaining what biometrics protect, toggle in Settings to enable/disable, clear description of protected operations, graceful degradation on devices without biometric hardware. Lockout: after 5 consecutive biometric failures, biometric is blocked (iOS indefinitely until device credential used, Android for 30 seconds escalating). After lockout, show device credential prompt automatically. Never let users get stuck — always offer the next fallback option.
Cross-platform implementation — Using Capacitor Biometric plugin (@capacitor/biometric): single API for both platforms, handles availability check, prompt, and fallback. Using Expo LocalAuthentication: similar cross-platform API with hasHardwareAsync(), isEnrolledAsync(), authenticateAsync(). For React Native: react-native-biometrics or react-native-keychain. Cross-platform wrappers simplify code but may not expose all platform-specific features (CryptoObject, fine-grained error handling, key invalidation control). For sensitive apps, use platform-native implementation with cross-platform coordination.
| Level | Android | iOS | Security |
|---|---|---|---|
| Strong (Class 3) | Fingerprint, Face (Class 3) | Face ID, Touch ID | High |
| Weak (Class 2) | Face unlock, Iris | Face ID with attention not required | Medium |
| Convenience (Class 1) | Basic face detection | None | Low |
| Device Credential | PIN, Pattern, Password | PIN, Password | Varies |
onAuthenticationError with ERROR_USER_CANCELED after biometric change leave old data accessible.NSFaceIDUsageDescription in Info.plist.localizedReason displayed in dialog — make it clear to user what's being authenticatedsetConfirmationRequired(true) adds button press to fingerprint — prevents accidental auth// Android — BiometricPrompt config
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle("Verify identity")
.setSubtitle("Access your secure vault")
.setAllowedAuthenticators(BIOMETRIC_STRONG or DEVICE_CREDENTIAL)
.setConfirmationRequired(false)
.build()
// iOS — LAContext
let context = LAContext()
context.localizedReason = "Unlock your premium features"
context.localizedFallbackTitle = "Enter Passcode"
context.touchIDAuthenticationAllowableReuseDuration = 10 // seconds cached
Biometric authentication is most valuable when used to protect cryptographic keys, not just as a boolean gate. The pattern: (1) generate a strong symmetric key (AES-256) in platform secure hardware, (2) bind the key to biometric authentication using setUserAuthenticationRequired(true), (3) use this key to encrypt/decrypt sensitive data (auth tokens, API keys, user credentials), (4) the key is only released when biometric authentication succeeds. Android Keystore provides this via KeyGenParameterSpec with setUserAuthenticationRequired(true) and setInvalidatedByBiometricEnrollment(true). iOS provides this via Keychain with SecAccessControlCreateWithFlags and biometryCurrentSet. For cross-platform, use platform-specific key storage and expose through a uniform SecureEnclave interface. Key invalidation on biometric enrollment change is critical security behavior — when user adds a new fingerprint/face, existing biometric keys are destroyed, requiring re-encryption of stored data. Apps must handle KeyPermanentlyInvalidatedException (Android) or errSecItemNotFound (iOS) gracefully by re-prompting for fresh credentials and re-encrypting.
Biometrics alone should not grant access to high-value operations. For payments, password changes, or PII viewing, combine biometrics with another factor (MFA). Decision tree:
Operation sensitivity level?
├── Low (view dashboard, app unlock) → Biometric only
├── Medium (view profile, read messages) → Biometric + device credential
├── High (payments, password change, delete account)
│ ├── Option A: Biometric + OTP (TOTP or SMS)
│ └── Option B: Biometric + server-issued challenge
└── Critical (large transfers, admin actions)
→ Biometric + OTP + device credential (3-factor)
The MFA flow: (1) user authenticates with biometric on device, (2) app sends proof of biometric auth (key attestation or signature) to server, (3) server verifies the attestation and issues an OTP challenge, (4) user enters OTP from authenticator app, (5) server validates and grants access. For offline MFA, use time-based OTP generated from a seed stored in the biometric-protected keystore.
Biometric authentication result?
├── Success → Grant access, cache auth for session
├── Failure (auth failed, fingerprint not recognized)
│ ├── Attempts < 5 → Show "Try again" with haptic feedback
│ └── Attempts >= 5 → Lockout biometric, force device credential
├── Error: biometric not enrolled → Redirect to Settings → enroll
├── Error: hardware unavailable → Fall through to device credential
├── Error: user canceled → Wait for next user action (don't immediately re-prompt)
├── Error: security update required → Show system update prompt
├── Error: sensor dirty/wet (Android specific) → "Clean sensor" message
└── Error: biometric changed (key invalidation) → Re-encrypt data with new biometric key
| Failure | Symptom | Mitigation |
|---|---|---|
| No biometric enrolled | Feature disabled | Guide user to Settings to enroll |
| Key invalidation on biometric change | Decryption fails on existing data | Catch KeyPermanentlyInvalidatedException, re-encrypt |
| Lockout (5+ failures) | Biometric unavailable | Force device credential, never show biometric UI |
| iOS Face ID not configured | canEvaluatePolicy returns false | Info.plist missing NSFaceIDUsageDescription |
| Android sensor dirty | "Try again" repeatedly | Guide user to clean sensor |
| Biometric hardware absent | BIOMETRIC_ERROR_HW_UNAVAILABLE | Disable biometric section, fallback to PIN |
NSFaceIDUsageDescription in Info.plist (iOS)BiometricManager.canAuthenticate(BIOMETRIC_STRONG) before showing prompt (Android)setAllowedAuthenticators() includes DEVICE_CREDENTIAL fallbacksetInvalidatedByBiometricEnrollment(true)USE_BIOMETRIC permissionLAContext calls without canEvaluatePolicy guardHow often should user re-authenticate?
├── Per-session (app launch → app background) → Cache on app foreground
│ Re-authenticate on return from background after timeout (5/15/60 min)
│ Valid only for the current app session — logout clears cache
├── Per-action (each sensitive operation) → No caching, require biometric each time
│ Use for: payments, password reveal, account deletion
│ UX: lightweight prompt (no full screen overlay)
├── Time-gated (re-auth every N minutes) → Biometric key with validity duration
│ Android: `setUserAuthenticationValidityDurationSeconds(300)` = 5 min cache
│ iOS: `LAContext.touchIDAuthenticationAllowableReuseDuration = 300`
│ Key remains accessible for duration without re-prompt
└── Device-unlock-gated → Use device credential (PIN) as proxy for biometric
User has already unlocked phone → trust the device auth
Low level of assurance — not for sensitive operations
Biometric enrollment changed (new finger/face added, or all removed)?
├── Key invalidated (setInvalidatedByBiometricEnrollment=true, biometryCurrentSet)
│ → Data becomes inaccessible: `KeyPermanentlyInvalidatedException` (Android)
│ → Must re-authenticate + re-encrypt all stored data
│ → Show clear message: "Your biometrics changed, please re-authenticate"
├── Key not invalidated (setInvalidatedByBiometricEnrollment=false)
│ → Old keys work even after new biometric enrolled
│ → Security risk: stolen biometric can decrypt old data
│ → Only use for: non-sensitive preferences, cached UI state
└── All biometrics removed from device
→ Keys remain if not invalidated by enrollment change
→ But no biometric available to unlock them — fallback to device credential
→ Recommend: delete biometric-protected data when no biometric enrolled
Best practice: never force biometrics on users. Implement a progressive disclosure flow: (a) on first launch, skip biometrics, (b) when user accesses protected feature, show a one-time nudge suggesting biometric setup, (c) if declined, respect the decision for 30 days before another nudge, (d) provide a permanent toggle in Settings to enable/disable, (e) if user disables biometrics, fall back to device credential permanently. The Settings screen should show: enrolled biometric type (Face ID, Fingerprint), enrollment status (X fingerprints, Face enrolled), toggle for each protected feature, and a "Change biometric" button that triggers showBiometric() to re-enroll the key in Keystore. Never show biometric as the only auth option — always have a device credential path.
class SecureStorage(private val context: Context) {
private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
private val keyGenerator = KeyGenerator.getInstance(
KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore"
)
fun createBiometricKey(keyName: String) {
val spec = KeyGenParameterSpec.Builder(keyName, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setUserAuthenticationRequired(true)
.setUserAuthenticationValidityDurationSeconds(10) // cache 10 seconds
.setInvalidatedByBiometricEnrollment(true)
.build()
keyGenerator.init(spec)
keyGenerator.generateKey()
}
fun encryptWithBiometric(keyName: String, data: String, onSuccess: (ByteArray) -> Unit, onError: (String) -> Unit) {
val secretKey = keyStore.getKey(keyName, null) as SecretKey
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE, secretKey)
val biometricPrompt = BiometricPrompt(
activity,
ContextCompat.getMainExecutor(context),
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
val encrypted = cipher.doFinal(data.toByteArray())
onSuccess(encrypted)
}
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
onError(errString.toString())
}
}
)
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle("Authenticate to access secure data")
.setSubtitle("Biometric required for decryption")
.setAllowedAuthenticators(BIOMETRIC_STRONG or DEVICE_CREDENTIAL)
.build()
biometricPrompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher))
}
}
import LocalAuthentication
import Security
class BiometricKeychain {
enum KeychainError: Error {
case itemNotFound, duplicateItem, unexpectedStatus(OSStatus)
}
func storeWithBiometric(service: String, account: String, data: Data) throws {
let accessControl = SecAccessControlCreateWithFlags(
nil,
kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
[.biometryCurrentSet],
nil
)!
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecValueData as String: data,
kSecAttrAccessControl as String: accessControl,
kSecUseAuthenticationContext as String: LAContext()
]
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else {
throw KeychainError.unexpectedStatus(status)
}
}
func readWithBiometric(service: String, account: String) throws -> Data {
let context = LAContext()
context.localizedReason = "Authenticate to access your stored credentials"
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecReturnData as String: true,
kSecUseAuthenticationContext as String: context,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess else {
throw KeychainError.unexpectedStatus(status)
}
return result as! Data
}
func delete(service: String, account: String) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account
]
let status = SecItemDelete(query as CFDictionary)
guard status == errSecSuccess else {
throw KeychainError.unexpectedStatus(status)
}
}
}
import ReactNativeBiometrics from 'react-native-biometrics';
const rnBiometrics = new ReactNativeBiometrics();
export async function checkBiometricAvailability() {
const { available, biometryType } = await rnBiometrics.isSensorAvailable();
return { available, type: biometryType };
// biometryType: Biometrics.FaceID | Biometrics.TouchID | Biometrics.Biometrics
}
export async function authenticateWithBiometric(reason: string) {
const { success } = await rnBiometrics.simplePrompt({ promptMessage: reason });
return success;
}
export async function createBiometricKey() {
const { publicKey } = await rnBiometrics.createKeys();
return publicKey; // send to server for challenge-response auth
}
export async function signWithBiometric(payload: string) {
const { success, signature } = await rnBiometrics.createSignature({
promptMessage: 'Sign in with biometrics',
payload,
});
return { success, signature };
}
setUserAuthenticationValidityDurationSeconds(86400) = 24 hours. A compromised device exposes data for a full day. Max recommended: 300 seconds (5 min).ACTION_BIOMETRIC_ENROLLMENT_CHANGED (Android) or check biometryCurrentSet on each app launch.ERROR_VENDOR (Android): Vendor-specific errors (OEM-specific biometric implementations) can crash the app. Never assume only standard error codes. Wrap in try/catch.LAContext.notRequireAttention allows face unlock with closed eyes. For high-sensitivity operations, require attention by relying on the default (attention required).| Operation | Typical Duration | Battery Impact |
|---|---|---|
| Face ID recognition | ~150ms | Minimal (Neural Engine) |
| Touch ID recognition | ~200ms | Minimal |
| Biometric key decryption | ~50ms | Negligible |
| Biometric prompt UI show | ~100ms | Negligible |
| Key generation (first time) | ~200-500ms | Small (one-time) |
| Key invalidation on enrollment change | ~10ms | Negligible |
Biometric operations are hardware-accelerated on modern devices (Secure Enclave / Titan M). They do not require network access. Battery impact is minimal — comparable to pressing a button. The only power concern is the camera-based Face ID on older iPhones (iPhone X/XR) which uses the TrueDepth camera briefly per authentication.
GDPR considers biometric data "special category" data requiring explicit consent. Key requirements: (a) biometric data must be processed on-device — never send raw biometric templates to servers, (b) user must explicitly opt-in to biometric auth with clear disclosure of what data is stored, (c) user must be able to revoke biometric consent at any time, (d) on revocation, all biometric-protected keys must be deleted, (e) biometric data must be encrypted at rest (which it is by default in Secure Enclave/Keystore), (f) data retention: biometric templates should not be stored long-term — use device-native biometric APIs that manage templates in the secure processor, (g) transparency: the privacy policy must disclose biometric usage, data storage location, and retention period.
| Test Scenario | Expected Behavior | Platform Differences |
|---|---|---|
| Enrolled biometric → success | Grant access | Both platforms |
| Wrong finger/face → failure | Show retry, count attempts | Both platforms |
| 5 failures → lockout | Force device credential | iOS: locks until device credential used. Android: 30s timeout |
| No biometric enrolled | Fallback to device credential | Check canAuthenticate first |
| Biometric enrollment change | Key invalidated, data inaccessible | Must re-encrypt data |
| Device credential entered | Grant access as fallback | Both |
| System biometric settings changed during app use | Check status on app return from background | Both |
| User cancels prompt | Wait for explicit next action | Both |
| Call interruption during auth | Resume or retry auth | Handle onAuthenticationError with ERROR_CANCELED |
| Accessibility: VoiceOver/TalkBack active | Biometric prompt must be accessible | Test with screen reader enabled |
class BiometricSessionCache {
private var lastAuthTimestamp: Date?
private let sessionTimeout: TimeInterval = 300 // 5 minutes
var isSessionValid: Bool {
guard let lastAuth = lastAuthTimestamp else { return false }
return Date().timeIntervalSince(lastAuth) < sessionTimeout
}
func authenticate(reason: String, completion: @escaping (Bool) -> Void) {
if isSessionValid {
completion(true) // Use cached auth
return
}
let context = LAContext()
context.localizedReason = reason
context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics,
localizedReason: reason) { success, error in
DispatchQueue.main.async {
if success {
self.lastAuthTimestamp = Date()
}
completion(success)
}
}
}
func invalidateSession() {
lastAuthTimestamp = nil // Called on logout or security event
}
}
Hand off to mobile-security skill for threat modeling and penetration testing of biometric auth paths.
` interface EventObserver { onEvent(event: T): Promise; }
class EventBus { private observers: Set<EventObserver> = new Set(); subscribe(observer: EventObserver): void { this.observers.add(observer); } unsubscribe(observer: EventObserver): void { this.observers.delete(observer); } async emit(event: T): Promise { const results = Array.from(this.observers).map(o => o.onEvent(event)); await Promise.allSettled(results); } } `
config: defaults: timeout: 30s retryCount: 3 overrides: production: timeout: 60s retryCount: 5 development: timeout: 300s retryCount: 1
| Metric | Threshold | Severity | Action |
|---|---|---|---|
| Error rate | > 1% over 5min | Critical | Page on-call |
| p99 latency | > 2s over 5min | Warning | Investigate |
| Throughput drop | > 50% over 1min | Critical | Check upstream |
| Queue depth | > 1000 over 1min | Warning | Scale consumers |
| Disk usage | > 85% | Warning | Clean or expand |
| Memory usage | > 90% heap | Critical | Restart or scale |
| Anti-Pattern | Symptom | Root Cause | Solution |
|---|---|---|---|
| Premature optimization | Complex code for no measured benefit | Guessing instead of profiling | Measure first, optimize based on data |
| Copy-paste reuse | Duplicate code across codebase | Lack of abstraction | Extract shared logic into libraries |
| Gold-plating | Features with no current requirement | Over-engineering | YAGNI — build what's needed now |
| Magical thinking | Assumptions without validation | Skipping error handling | Handle all failure modes explicitly |
Cache hierarchy: L1 (in-memory local) → L2 (distributed Redis/Memcached) → L3 (CDN/Edge). Cache invalidation: TTL-based (simple, stale), event-based (complex, fresh), write-through (consistent, higher write latency), write-behind (fast writes, eventual consistency).