Mobile No. 341

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.

  • namemobile-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

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.

~/.claude/skills/mobile-biometric-auth/SKILL.md

--- name: mobile-biometric-auth description: Implements Face ID, Touch ID, and Android BiometricPrompt unlock the secure way — secrets in the Keychain or Android Keystore bound to… license: MIT ---

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

PatternBypassable byUse for
evaluatePolicy / authenticate() returns true, app proceedsRuntime hooking (Frida) on a rooted/jailbroken device flips the resultCosmetic app lock only
Keychain item with biometric SecAccessControl; reading it triggers Face IDRequires breaking Secure EnclaveReleasing a refresh token
Keystore key with setUserAuthenticationRequired, used via CryptoObjectSame (TEE/StrongBox)Decrypting a stored token
Device key pair signs a server nonce after biometric unlockSame, and server verifies each useStep-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.plist must contain NSFaceIDUsageDescription, or the app crashes on first Face ID use.
  • .biometryCurrentSet is the secure default; .biometryAny survives new enrollments (a thief who knows the passcode can add their face). Add .or + .devicePasscode only if the product accepts passcode as equivalent.
  • SecItemCopyMatching blocks while the prompt is up; run it from a background task.
  • Availability: LAContext().canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error:); read biometryType to 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_ENROLLED can deep-link to Settings.ACTION_BIOMETRIC_ENROLL.
  • CryptoObject requires BIOMETRIC_STRONG (Class 3). BIOMETRIC_WEAK (many face unlocks) cannot unlock Keystore keys; use it only for convenience locks.
  • BIOMETRIC_STRONG or DEVICE_CREDENTIAL with a CryptoObject is not supported on API 28-29; on those versions offer password fallback in your own UI instead.
  • When DEVICE_CREDENTIAL is allowed, do not call setNegativeButtonText (it throws).
  • KeyPermanentlyInvalidatedException on init: 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

SituationiOSAndroidAction
User cancelserrSecUserCanceled / LAError.userCancelERROR_USER_CANCELED, ERROR_NEGATIVE_BUTTONShow password login, no error toast
Too many attemptsLAError.biometryLockoutERROR_LOCKOUT (30 s), ERROR_LOCKOUT_PERMANENTOffer passcode or password; do not loop the prompt
Enrollment changedKeychain item not foundKeyPermanentlyInvalidatedExceptionWipe, password login, re-offer
Not enrolled / no hardwarebiometryNotEnrolled / biometryNotAvailableBIOMETRIC_ERROR_NONE_ENROLLED / NO_HARDWAREHide the setting
App backgrounded mid-promptLAError.systemCancel / appCancelERROR_CANCELEDRe-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

StackLibrarySecure mode
Expo / React Nativeexpo-secure-store with requireAuthentication: true, or react-native-keychain with access controlBoth bind the item to biometrics; expo-local-authentication alone is only a boolean check
Flutterflutter_secure_storage with biometric options, or local_auth + platform channellocal_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 (kSecAttrTokenIDSecureEnclave with 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.
  • kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly items vanish if the user removes the passcode; treat as invalidation.
  • Never log or analytics-track the biometric result payload or the decrypted token.

Verification

  1. Enable biometrics, kill the app, relaunch: prompt appears and the token is released.
  2. Add a new fingerprint/face in Settings: next unlock must fail closed into password login.
  3. Fail biometrics until lockout: the app offers the fallback and does not crash or loop.
  4. Inspect storage: no token in plaintext preferences, UserDefaults, or logs.
  5. 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.

esc