Mobile Biometric Auth skill for Claude Code
Face ID that guards a key, not a boolean. Claude implements biometric unlock so the prompt releases a token sealed in the Keychain or Android Keystore, with biometryCurrentSet and setInvalidatedByBiometricEnrollment so a newly added face or finger locks the secret. It maps every cancel, lockout and invalidation error to a password fallback, keeps the server issuing short-lived tokens, and flags wrappers that only return a bypassable true.
- name
mobile-biometric-auth - size224 lines · ≈2,900 tokens
- files1
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Implements Face ID, Touch ID, and Android BiometricPrompt unlock the secure way — secrets in the Keychain or Android Keystore bound to biometric access control and CryptoObject instead of a boolean check, enrollment-change invalidation, BIOMETRIC_STRONG vs. device credential choices, passcode fallback and lockout flows, opt-in UX, and React Native and Flutter wrappers. Use when adding biometric login or app lock, protecting a refresh token behind Face ID, handling KeyPermanentlyInvalidatedException, or reviewing whether a biometric check can be bypassed. Try asking
- Add Face ID and fingerprint unlock that protects our refresh token on iOS and Android
- Users get KeyPermanentlyInvalidatedException after adding a fingerprint — handle it properly
- Review our React Native biometric login: can it be bypassed on a rooted phone?
Use it when
- Adding biometric sign-in or an app lock screen
- Storing a session or refresh token behind Face ID or fingerprint
- Handling lockout, cancel, or enrollment-change errors
- Auditing whether a biometric check gates a real secret
Skip it when
- Building passwordless sign-in that syncs across devices — use passkeys-webauthn
- Broader binary hardening, root detection, or certificate pinning — use mobile-app-security
How to check the result
After enrolling a new face or fingerprint the stored secret can no longer be read, lockout falls back to password, and no token appears in plaintext storage.
The common mistake it prevents
Treating the prompt's success callback as authentication instead of binding a Keychain item or Keystore key to it, which hooking tools bypass in seconds.
Done well means: the biometric prompt unlocks a secret held by the Secure Enclave or
Keystore (not a true returned to app code), adding a new fingerprint or face invalidates
that secret, the user always has a non-biometric path back in, and the server still
authenticates the device or session rather than trusting a client-side flag.
Before changing anything
- Decide what biometrics protect: local app lock, release of a stored refresh token, or signing a server challenge (step-up for payments). Each needs a different strength.
- Find where the session token lives today (UserDefaults, SharedPreferences, AsyncStorage are plaintext; move it).
- Check minimum OS versions and the biometric library already in use.
- If the goal is passwordless login across devices, passkeys are the better primitive; biometrics only unlock a credential that already exists on this device.
Threat model in one table
| Pattern | Bypassable by | Use for |
|---|---|---|
evaluatePolicy / authenticate() returns true, app proceeds | Runtime hooking (Frida) on a rooted/jailbroken device flips the result | Cosmetic app lock only |
Keychain item with biometric SecAccessControl; reading it triggers Face ID | Requires breaking Secure Enclave | Releasing a refresh token |
Keystore key with setUserAuthenticationRequired, used via CryptoObject | Same (TEE/StrongBox) | Decrypting a stored token |
| Device key pair signs a server nonce after biometric unlock | Same, and server verifies each use | Step-up auth, high-value actions |
Default to the second or third row. Use the fourth for payments, transfers, or anything a stolen unlocked phone must not do silently.
Workflow
- [ ] 1. Decide scope: app lock, token release, or server challenge signing
- [ ] 2. Check availability and enrollment; hide the option when unavailable
- [ ] 3. Opt-in after a successful password login, never default-on silently
- [ ] 4. Store the secret behind biometric access control (Keychain / Keystore)
- [ ] 5. Handle invalidation: new enrollment, removed passcode, lockout
- [ ] 6. Fallback: device passcode or account password, always reachable
- [ ] 7. Server: short-lived access tokens, refresh token rotation, revoke on logout
- [ ] 8. Test on real devices: enroll/unenroll, lockout, cancel, background mid-prompt
iOS: Keychain with biometric access control
import LocalAuthentication
import Security
func storeRefreshToken(_ token: Data) throws {
var error: Unmanaged<CFError>?
guard let access = SecAccessControlCreateWithFlags(
nil,
kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly, // deleted if passcode removed; no backup/migration
.biometryCurrentSet, // invalidated when Face/Touch ID enrollment changes
&error) else { throw error!.takeRetainedValue() as Error }
let base: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.acme.auth",
kSecAttrAccount as String: "refresh-token",
]
SecItemDelete(base as CFDictionary)
var add = base
add[kSecValueData as String] = token
add[kSecAttrAccessControl as String] = access
let status = SecItemAdd(add as CFDictionary, nil)
guard status == errSecSuccess else { throw KeychainError(status) }
}
func readRefreshToken(reason: String) throws -> Data {
let context = LAContext()
context.localizedReason = reason
context.localizedCancelTitle = "Use Password"
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.acme.auth",
kSecAttrAccount as String: "refresh-token",
kSecReturnData as String: true,
kSecUseAuthenticationContext as String: context,
]
var out: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &out) // shows Face ID; call off the main thread
switch status {
case errSecSuccess: return out as! Data
case errSecUserCanceled: throw AuthError.cancelled
case errSecItemNotFound: throw AuthError.invalidated // enrollment changed or never stored
default: throw KeychainError(status)
}
}
Info.plistmust containNSFaceIDUsageDescription, or the app crashes on first Face ID use..biometryCurrentSetis the secure default;.biometryAnysurvives new enrollments (a thief who knows the passcode can add their face). Add.or+.devicePasscodeonly if the product accepts passcode as equivalent.SecItemCopyMatchingblocks while the prompt is up; run it from a background task.- Availability:
LAContext().canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error:); readbiometryTypeto label the button “Face ID”, “Touch ID”, or “Optic ID”. - Detect enrollment changes for app-lock-only flows with
evaluatedPolicyDomainState(compare to a stored value); the Keychain path handles it automatically.
Android: Keystore key + BiometricPrompt CryptoObject
private const val KEY = "refresh_token_key"
fun createKey() {
val spec = KeyGenParameterSpec.Builder(KEY,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setUserAuthenticationRequired(true)
.setUserAuthenticationParameters(0, KeyProperties.AUTH_BIOMETRIC_STRONG) // every use
.setInvalidatedByBiometricEnrollment(true)
.build()
KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
.apply { init(spec) }.generateKey()
}
fun decryptCipher(iv: ByteArray): Cipher {
val key = (KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
.getKey(KEY, null) as SecretKey)
return Cipher.getInstance("AES/GCM/NoPadding").apply {
init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(128, iv)) // throws KeyPermanentlyInvalidatedException
}
}
fun unlock(activity: FragmentActivity, iv: ByteArray, encrypted: ByteArray, onToken: (ByteArray) -> Unit) {
val prompt = BiometricPrompt(activity, ContextCompat.getMainExecutor(activity),
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
val cipher = result.cryptoObject?.cipher ?: return
onToken(cipher.doFinal(encrypted)) // only works because the user authenticated
}
override fun onAuthenticationError(code: Int, msg: CharSequence) { handleError(code) }
})
val info = BiometricPrompt.PromptInfo.Builder()
.setTitle("Unlock Acme")
.setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG)
.setNegativeButtonText("Use password")
.build()
prompt.authenticate(info, BiometricPrompt.CryptoObject(decryptCipher(iv)))
}
- Check first:
BiometricManager.from(ctx).canAuthenticate(BIOMETRIC_STRONG);BIOMETRIC_ERROR_NONE_ENROLLEDcan deep-link toSettings.ACTION_BIOMETRIC_ENROLL. CryptoObjectrequiresBIOMETRIC_STRONG(Class 3).BIOMETRIC_WEAK(many face unlocks) cannot unlock Keystore keys; use it only for convenience locks.BIOMETRIC_STRONG or DEVICE_CREDENTIALwith aCryptoObjectis not supported on API 28-29; on those versions offer password fallback in your own UI instead.- When
DEVICE_CREDENTIALis allowed, do not callsetNegativeButtonText(it throws). KeyPermanentlyInvalidatedExceptiononinit: delete the key and stored ciphertext, require password login, then offer to re-enable biometrics.- Use
androidx.biometric(stable 1.1.x line) rather than the framework class for consistent behaviour across API levels; newer alpha releases add an activity-result and Compose API, adopt them only once stable. - Store the IV and ciphertext in regular storage (DataStore/SharedPreferences); they are useless without the key.
Errors and fallback
| Situation | iOS | Android | Action |
|---|---|---|---|
| User cancels | errSecUserCanceled / LAError.userCancel | ERROR_USER_CANCELED, ERROR_NEGATIVE_BUTTON | Show password login, no error toast |
| Too many attempts | LAError.biometryLockout | ERROR_LOCKOUT (30 s), ERROR_LOCKOUT_PERMANENT | Offer passcode or password; do not loop the prompt |
| Enrollment changed | Keychain item not found | KeyPermanentlyInvalidatedException | Wipe, password login, re-offer |
| Not enrolled / no hardware | biometryNotEnrolled / biometryNotAvailable | BIOMETRIC_ERROR_NONE_ENROLLED / NO_HARDWARE | Hide the setting |
| App backgrounded mid-prompt | LAError.systemCancel / appCancel | ERROR_CANCELED | Re-prompt on next foreground, not immediately |
UX rules
- Offer biometrics right after a successful password login (“Use Face ID next time?”). Never enable it before the user has proven they know the password.
- Prompt reason text names the action (“Sign in to Acme”, “Approve transfer of $120”).
- Do not auto-prompt on every foreground of a low-risk app; set an inactivity timeout (e.g. 5 minutes) before re-locking.
- Logout deletes the Keychain item / Keystore key and the server revokes the refresh token.
- Hide sensitive content in the app switcher snapshot while locked.
Cross-platform
| Stack | Library | Secure mode |
|---|---|---|
| Expo / React Native | expo-secure-store with requireAuthentication: true, or react-native-keychain with access control | Both bind the item to biometrics; expo-local-authentication alone is only a boolean check |
| Flutter | flutter_secure_storage with biometric options, or local_auth + platform channel | local_auth.authenticate() alone is only a boolean check |
Verify the wrapper’s actual native flags (access control, invalidation on enrollment) in its docs or source before trusting it with tokens.
Server side
- Biometric unlock releases a refresh token; the server still issues short-lived access tokens (minutes), rotates refresh tokens on use, and detects reuse.
- For step-up: generate an EC key pair in the Secure Enclave
(
kSecAttrTokenIDSecureEnclavewith biometric access control) or Keystore, register the public key, and have the device sign a server nonce for each sensitive action. Use Play Integrity or App Attest if you must know the app is genuine.
Gotchas
- Keychain items survive app uninstall on iOS. On first launch after install, clear stale auth items (track a “has launched” flag in UserDefaults, which is wiped on uninstall).
- Simulators fake biometrics (Features > Face ID > Matching Face); Keystore auth-bound keys on emulators need an enrolled fingerprint via extended controls. Test lockout and invalidation on real hardware.
kSecAttrAccessibleWhenPasscodeSetThisDeviceOnlyitems vanish if the user removes the passcode; treat as invalidation.- Never log or analytics-track the biometric result payload or the decrypted token.
Verification
- Enable biometrics, kill the app, relaunch: prompt appears and the token is released.
- Add a new fingerprint/face in Settings: next unlock must fail closed into password login.
- Fail biometrics until lockout: the app offers the fallback and does not crash or loop.
- Inspect storage: no token in plaintext preferences, UserDefaults, or logs.
- Logout: Keychain item / Keystore key deleted and server refresh token revoked.
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.