Biometric authentication on mobile: what the OS guarantees and what it doesn't

When users authenticate with Face ID or a fingerprint, they are not sending their biometric data to your server. They are not even sending it to your app. The biometric verification happens entirely inside a hardware security boundary, and what your application gets back is either a cryptographic signature or access to a protected keychain item. Understanding this model is critical to building biometric auth correctly — and understanding what you are and are not protected against.

The Secure Enclave and what it actually does

On Apple devices since iPhone 5s, the Secure Enclave is a separate processor with its own isolated memory. It runs its own microkernel, and the main application processor cannot read its memory. When you enroll a fingerprint or configure Face ID, the biometric templates are stored in the Secure Enclave, never in the application processor's memory or on disk in a form the OS can access.

For authentication, the Secure Enclave stores private keys. Your application generates an asymmetric keypair, stores the private key in the Secure Enclave with a biometric protection policy, and registers the public key with your server. When the user authenticates biometrically, the Secure Enclave performs a signing operation using the protected private key and returns the signature to the application. The private key never leaves the enclave.

// iOS: creating a Secure Enclave key requiring biometric auth
// Swift
import LocalAuthentication
import Security

func createBiometricKey() throws -> SecKey {
    var error: Unmanaged<CFError>?

    let access = SecAccessControlCreateWithFlags(
        kCFAllocatorDefault,
        kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
        [.privateKeyUsage, .biometryCurrentSet],  // invalidate on biometry change
        &error
    )
    guard let access = access, error == nil else {
        throw BiometricError.accessControlFailed
    }

    let attributes: [String: Any] = [
        kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
        kSecAttrKeySizeInBits as String: 256,
        kSecAttrTokenID as String: kSecAttrTokenIDSecureEnclave,
        kSecPrivateKeyAttrs as String: [
            kSecAttrIsPermanent as String: true,
            kSecAttrApplicationTag as String: "com.example.biometricKey",
            kSecAttrAccessControl as String: access
        ]
    ]

    guard let privateKey = SecKeyCreateRandomKey(attributes as CFDictionary, &error),
          error == nil else {
        throw BiometricError.keyGenerationFailed
    }
    return privateKey
}

func signChallenge(_ challenge: Data, privateKey: SecKey) throws -> Data {
    var error: Unmanaged<CFError>?

    guard let signature = SecKeyCreateSignature(
        privateKey,
        .ecdsaSignatureMessageX962SHA256,
        challenge as CFData,
        &error
    ) as Data? else {
        throw BiometricError.signingFailed
    }
    return signature
}

Note the .biometryCurrentSet flag. This policy causes the key to be invalidated if the enrolled biometrics change — if the user adds a new fingerprint or re-enrolls Face ID, the key is deleted. This is intentional: it prevents an attacker who gains physical access to the device from adding their own biometric and gaining access. You should always use this flag for authentication keys.

Android StrongBox and the Keystore

Android has two levels of hardware security: the Android Keystore backed by the Trusted Execution Environment (TEE), and StrongBox, which is a discrete security chip (present on higher-end devices since Android 9) with properties similar to Apple's Secure Enclave — physically isolated, tamper-resistant, with its own CPU and memory.

// Android: generating a key requiring biometric auth
// Kotlin
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import java.security.KeyPairGenerator

fun createBiometricKey(): KeyPair {
    val keyPairGenerator = KeyPairGenerator.getInstance(
        KeyProperties.KEY_ALGORITHM_EC,
        "AndroidKeyStore"
    )

    val parameterSpec = KeyGenParameterSpec.Builder(
        "biometric_auth_key",
        KeyProperties.PURPOSE_SIGN or KeyProperties.PURPOSE_VERIFY
    ).apply {
        setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1"))
        setDigests(KeyProperties.DIGEST_SHA256)
        setUserAuthenticationRequired(true)
        // Require fresh biometric for every use (timeout = 0)
        setUserAuthenticationParameters(0, KeyProperties.AUTH_BIOMETRIC_STRONG)
        // Request StrongBox if available
        setIsStrongBoxBacked(true)
        // Invalidate on new biometric enrollment
        setInvalidatedByBiometricEnrollment(true)
    }.build()

    keyPairGenerator.initialize(parameterSpec)
    return keyPairGenerator.generateKeyPair()
}

// Signing with biometric prompt
fun signWithBiometric(
    challenge: ByteArray,
    activity: FragmentActivity,
    onSuccess: (ByteArray) -> Unit,
    onError: (String) -> Unit
) {
    val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
    val privateKey = keyStore.getKey("biometric_auth_key", null) as PrivateKey

    val signature = Signature.getInstance("SHA256withECDSA").apply {
        initSign(privateKey)
    }

    val cryptoObject = BiometricPrompt.CryptoObject(signature)
    val prompt = BiometricPrompt(activity, executor, object : BiometricPrompt.AuthenticationCallback() {
        override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
            val sig = result.cryptoObject?.signature!!
            sig.update(challenge)
            onSuccess(sig.sign())
        }
        override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
            onError(errString.toString())
        }
    })

    val promptInfo = BiometricPrompt.PromptInfo.Builder()
        .setTitle("Confirm identity")
        .setNegativeButtonText("Use password")
        .setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG)
        .build()

    prompt.authenticate(promptInfo, cryptoObject)
}
On Android, BIOMETRIC_STRONG requires a hardware-backed authenticator (Class 3). Do not use BIOMETRIC_WEAK (Class 2) or DEVICE_CREDENTIAL for authentication keys that protect sensitive data. Class 2 biometrics are not backed by the TEE and can be bypassed by modifying app data on rooted devices.

LAContext and what it does not give you

On iOS, LAContext.evaluatePolicy is the high-level biometric API. It returns a boolean indicating whether the user authenticated successfully. Many developers use this alone — they call evaluatePolicy, get true, and proceed to unlock app features. This is weaker than it looks.

LAContext does not give you a cryptographic proof. On a jailbroken device, the result of evaluatePolicy can be spoofed by hooking the method and returning true without any biometric being presented. The correct pattern is to use biometrics to unlock a Secure Enclave key, then use that key to sign a server-issued challenge. The server verifies the signature. If the signing operation succeeds, the Secure Enclave actually performed biometric verification — this cannot be spoofed at the app level.

// Server-side: verify the biometric authentication
app.post('/auth/biometric/verify', async (req, res) => {
    const { user_id, challenge_id, signature } = req.body;

    // Retrieve the challenge issued to this device
    const challenge = await redis.getdel(`biometric:challenge:${challenge_id}`);
    if (!challenge) {
        return res.status(400).json({ error: 'invalid or expired challenge' });
    }

    // Look up the registered public key for this user/device
    const device = await db.biometricDevices.findOne({ user_id, challenge_id_hint: challenge_id });
    if (!device) {
        return res.status(400).json({ error: 'device not registered' });
    }

    // Verify the ECDSA signature
    const verify = crypto.createVerify('SHA256');
    verify.update(Buffer.from(challenge, 'hex'));
    const valid = verify.verify(device.public_key_pem, Buffer.from(signature, 'base64'));

    if (!valid) {
        return res.status(401).json({ error: 'signature verification failed' });
    }

    const tokens = await issueTokens(user_id, device.device_id);
    res.json(tokens);
});

What biometric auth does not protect against

Biometric authentication protects against one specific threat: an unauthorized person using the device without the owner's physical biometric. It does not protect against:

  • Compromised server-side credentials — if the server is breached, biometric auth on the device is irrelevant to the server tokens already issued.
  • Session hijacking — a stolen access token is valid regardless of how the user authenticated to get it.
  • Compelled authentication — legal or physical coercion. In high-risk environments, a fallback PIN that cannot be compelled by observing body parts may be preferable to biometrics.
  • Same-device malware — if the app process itself is compromised, an attacker can call your own biometric authentication code and capture the resulting tokens.
  • Identical twin or close family member with similar biometrics — Face ID's false accept rate is approximately 1 in 1,000,000 for random people, but significantly higher for family members.

Used correctly — as a device-local gate protecting a hardware-backed cryptographic key, with server-side challenge-response verification — biometric authentication is genuinely strong. Used as a simple boolean gate, it is weaker than it appears and creates false confidence.

← Back to blog Try Bastionary free →