Biometrics are not authentication (and that’s the point)
Most Android apps still treat biometrics like this:
“If Face ID succeeds, the user is logged in, so we can show secrets.”
That mindset is how you end up with:
- tokens stored unencrypted in preferences,
- decrypted payloads sitting in memory,
- “secure screens” that are actually just UI gates.
In 2025, the more accurate mental model is:
Biometrics are OS-mediated authorization to use a cryptographic primitive — nothing more.
Your app shouldn’t “trust” biometrics.
It should ask the OS to allow a specific crypto operation right now, and only if the user is verified.
That difference is everything.
What the Keystore actually gives you (when used correctly)
When you use Android Keystore correctly:
- your private key material is generated inside the system keystore,
- it may be hardware-backed (TEE / StrongBox),
- it’s non-exportable (your app never sees raw key bytes),
- biometric verification can be enforced as a requirement to use the key.
Even if an attacker:
- roots a device,
- dumps your app’s memory,
- hooks your classes,
- attaches a debugger,
they still can’t “steal the key” if the key is hardware-backed and non-exportable.
They can only try to trick your app into using it — which is why flow design matters.
The 2025 “secure by design” baseline (what you should aim for)
If you’re building anything that handles real identity or money (or just user trust), your baseline should look like:
- Encrypt secrets at rest (tokens, private notes, cached PII).
- Keys are hardware-backed when possible (StrongBox if available).
- Keys are biometric-bound (or device credential fallback where required).
- Decryption is scoped and ephemeral (decrypt only what you need, for the shortest time).
- Keys are invalidated on biometric enrollment changes (when it makes sense).
- Strong failure handling: lockout, enrollment changes, no hardware, etc.
BiometricPrompt + CryptoObject: the only pattern that matters
A BiometricPrompt without a CryptoObject is basically “biometric as UI”.
A BiometricPrompt with a CryptoObject is “biometric as cryptographic authorization”.
The secure flow:
- Create or retrieve a Keystore key
- Initialize
CipherinENCRYPT_MODEorDECRYPT_MODE - Pass it into
BiometricPrompt.CryptoObject(cipher) - The
Cipherbecomes usable only after the OS grants the biometric gate.
This design ensures you don’t accidentally decrypt secrets before the biometric check.
Threat model (quick, but practical)
If your app stores tokens locally, your real threats in 2025 often are:
- Local extraction (rooted phones, adb backups, filesystem access)
- Runtime tampering (Frida / Xposed hooking, repackaging)
- Overlay & accessibility abuse
- Credential theft (phishing, stolen session tokens)
- Replay (tokens copied from another device)
- Weak fallback (biometrics fail → app falls back to insecure logic)
Keystore + Biometrics doesn’t solve everything — it solves one major thing:
Strong protection for secrets at rest, tied to the user’s presence.
Implementation (production-grade)
Dependencies
KOTLINdependencies {
implementation("androidx.biometric:biometric:1.2.0-alpha05") // or latest stable
implementation("androidx.security:security-crypto:1.1.0-alpha06") // optional, see notes below
}
Note:
security-cryptois great, but for a true “advanced” article we’ll implement directly with Keystore + Cipher to control behavior precisely.
Step 1 — Key strategy: AES-GCM, biometric gated
AES-GCM is the go-to for local encryption:
- confidentiality + integrity (tamper detection),
- good performance,
- well supported.
Key creation with biometric enforcement
KOTLINimport android.os.Build
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import java.security.KeyStore
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
private const val ANDROID_KEYSTORE = "AndroidKeyStore"
private const val KEY_ALIAS = "secure_aes_gcm_biometric_key"
object KeystoreKeys {
fun getOrCreateAesKey(): SecretKey {
val existing = getSecretKey(KEY_ALIAS)
if (existing != null) return existing
val keyGenerator = KeyGenerator.getInstance(
KeyProperties.KEY_ALGORITHM_AES,
ANDROID_KEYSTORE
)
val builder = KeyGenParameterSpec.Builder(
KEY_ALIAS,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(256)
.setUserAuthenticationRequired(true)
// Require biometric (strong) every time (no validity window).
.setUserAuthenticationParameters(
0,
KeyProperties.AUTH_BIOMETRIC_STRONG
)
// If new biometric is enrolled, invalidate the key (optional: discuss below).
.setInvalidatedByBiometricEnrollment(true)
// StrongBox is not universally available; request when supported.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
builder.setIsStrongBoxBacked(true)
}
keyGenerator.init(builder.build())
return keyGenerator.generateKey()
}
private fun getSecretKey(alias: String): SecretKey? {
val ks = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
val entry = ks.getEntry(alias, null) as? KeyStore.SecretKeyEntry
return entry?.secretKey
}
}
A note on invalidation choice
setInvalidatedByBiometricEnrollment(true) is a tradeoff:
- ✅ Security: if biometrics change, your local secrets become unreadable.
- ❌ UX: user might need to re-login or re-encrypt.
For banking/identity apps, invalidation is often desirable. For notes apps or offline tools, you might prefer device credential fallback instead.
Step 2 — Cipher initialization (AES/GCM) with best defaults
GCM requires an IV (nonce). Android will generate one when encrypting; you must store it alongside ciphertext.
KOTLINimport javax.crypto.Cipher
import javax.crypto.spec.GCMParameterSpec
private const val TRANSFORMATION = "AES/GCM/NoPadding"
private const val GCM_TAG_BITS = 128
object Crypto {
fun cipherEncrypt(secretKey: SecretKey): Cipher =
Cipher.getInstance(TRANSFORMATION).apply {
init(Cipher.ENCRYPT_MODE, secretKey)
}
fun cipherDecrypt(secretKey: SecretKey, iv: ByteArray): Cipher =
Cipher.getInstance(TRANSFORMATION).apply {
init(
Cipher.DECRYPT_MODE,
secretKey,
GCMParameterSpec(GCM_TAG_BITS, iv)
)
}
}
Step 3 — BiometricPrompt that unlocks crypto (correctly)
You want a reusable helper that:
- checks capability,
- shows biometric UI,
- returns a usable cipher.
KOTLINimport android.content.Context
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
import java.util.concurrent.Executor
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlinx.coroutines.suspendCancellableCoroutine
sealed class BioCryptoResult {
data class Success(val cipher: Cipher) : BioCryptoResult()
data class Failure(val errorCode: Int, val errString: CharSequence) : BioCryptoResult()
object Cancelled : BioCryptoResult()
}
object BioCryptoPrompt {
fun canAuthenticate(context: Context): Int {
val manager = BiometricManager.from(context)
return manager.canAuthenticate(
BiometricManager.Authenticators.BIOMETRIC_STRONG
)
}
suspend fun authenticateForCrypto(
activity: FragmentActivity,
cryptoObject: BiometricPrompt.CryptoObject,
title: String = "Unlock"
): BioCryptoResult = suspendCancellableCoroutine { cont ->
val executor: Executor = ContextCompat.getMainExecutor(activity)
val callback = object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
val cipher = result.cryptoObject?.cipher
if (cipher != null) cont.resume(BioCryptoResult.Success(cipher))
else cont.resumeWithException(IllegalStateException("Cipher missing from CryptoObject"))
}
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
if (errorCode == BiometricPrompt.ERROR_USER_CANCELED ||
errorCode == BiometricPrompt.ERROR_NEGATIVE_BUTTON ||
errorCode == BiometricPrompt.ERROR_CANCELED
) cont.resume(BioCryptoResult.Cancelled)
else cont.resume(BioCryptoResult.Failure(errorCode, errString))
}
override fun onAuthenticationFailed() {
// Non-fatal: user can try again
}
}
val prompt = BiometricPrompt(activity, executor, callback)
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle(title)
.setSubtitle("Confirm your identity to unlock secure data")
.setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG)
.build()
prompt.authenticate(promptInfo, cryptoObject)
cont.invokeOnCancellation {
// BiometricPrompt doesn't provide a direct cancel hook here,
// but cancellation is handled via lifecycle.
}
}
}
Step 4 — Encrypt and store: ciphertext + IV + metadata
You need a safe container for storage. For simplicity, use:
- Room, DataStore, or encrypted file.
- This example uses a generic data model you can store anywhere.
KOTLINimport java.util.Base64
data class EncryptedBlob(
val cipherTextB64: String,
val ivB64: String,
val scheme: String = "AES_GCM_256"
) {
companion object {
fun from(cipherText: ByteArray, iv: ByteArray): EncryptedBlob =
EncryptedBlob(
cipherTextB64 = Base64.getEncoder().encodeToString(cipherText),
ivB64 = Base64.getEncoder().encodeToString(iv)
)
}
fun cipherText(): ByteArray = Base64.getDecoder().decode(cipherTextB64)
fun iv(): ByteArray = Base64.getDecoder().decode(ivB64)
}
Encryption flow (biometric-gated)
KOTLINsuspend fun encryptWithBiometrics(
activity: FragmentActivity,
plainText: ByteArray
): EncryptedBlob {
val key = KeystoreKeys.getOrCreateAesKey()
val cipher = Crypto.cipherEncrypt(key)
val result = BioCryptoPrompt.authenticateForCrypto(
activity,
BiometricPrompt.CryptoObject(cipher),
title = "Encrypt secret"
)
return when (result) {
is BioCryptoResult.Success -> {
val unlockedCipher = result.cipher
val cipherText = unlockedCipher.doFinal(plainText)
val iv = unlockedCipher.iv
EncryptedBlob.from(cipherText, iv)
}
is BioCryptoResult.Failure -> throw SecurityException("Biometric error: ${result.errorCode} ${result.errString}")
BioCryptoResult.Cancelled -> throw CancellationException("User cancelled biometric prompt")
}
}
Step 5 — Decrypt only when needed (and wipe fast)
Here’s the key practice that separates “secure” apps from “just looks secure”:
Decrypt as late as possible, keep it in memory briefly, and avoid long-lived references.
KOTLINsuspend fun decryptWithBiometrics(
activity: FragmentActivity,
blob: EncryptedBlob
): ByteArray {
val key = KeystoreKeys.getOrCreateAesKey()
val cipher = Crypto.cipherDecrypt(key, blob.iv())
val result = BioCryptoPrompt.authenticateForCrypto(
activity,
BiometricPrompt.CryptoObject(cipher),
title = "Decrypt secret"
)
return when (result) {
is BioCryptoResult.Success -> {
val unlockedCipher = result.cipher
unlockedCipher.doFinal(blob.cipherText())
}
is BioCryptoResult.Failure -> throw SecurityException("Biometric error: ${result.errorCode} ${result.errString}")
BioCryptoResult.Cancelled -> throw CancellationException("User cancelled biometric prompt")
}
}
Wiping memory (practical note)
JVM/Kotlin doesn’t guarantee perfect memory wiping, but you can reduce risk:
- use
ByteArrayfor secrets, - overwrite arrays when done.
KOTLINfun ByteArray.wipe() {
fill(0)
}
Use it right after you’re done:
KOTLINval secret = decryptWithBiometrics(activity, blob)
try {
// use secret
} finally {
secret.wipe()
}
“But I need a fallback!” (Make it secure, not convenient)
Biometric fallback is where security usually dies.
The rule
If biometrics fail, fallback should still be OS-trusted, not “enter app PIN we made ourselves”.
In 2025, the right fallback is usually:
- Device credential (PIN/pattern/password), or
- a forced re-auth (server login / refresh)
Allow device credential fallback (optional)
KOTLINval allowed = BiometricManager.Authenticators.BIOMETRIC_STRONG or
BiometricManager.Authenticators.DEVICE_CREDENTIAL
And update:
KOTLIN.setAllowedAuthenticators(allowed)
But if you enable device credential, update your key params too:
- on newer APIs you can permit device credential usage in
setUserAuthenticationParameters.
Handling key invalidation (the “biometrics changed” problem)
When a key is invalidated (e.g., new fingerprint enrolled), decryption will throw.
The error often looks like:
KeyPermanentlyInvalidatedException
This is not a bug — it’s a feature.
Recommended response
- clear encrypted local secrets,
- force user to re-auth,
- re-create the key,
- re-encrypt fresh secrets.
KOTLINfun resetKeyAndData() {
val ks = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
if (ks.containsAlias(KEY_ALIAS)) ks.deleteEntry(KEY_ALIAS)
// Also delete encrypted blobs/tokens from storage
}
StrongBox: what it is and when you should care
StrongBox is a discrete secure element on some devices. When available, it’s generally stronger than standard TEE-backed keystore.
Reality:
- Not all devices have it
- Some have it but fail due to lack of secure storage capacity
- You must handle failure gracefully
In production, you typically do:
- try create StrongBox key
- if fails, fall back to TEE
Keep your UX stable and log metrics so you can see real-world coverage.
What to encrypt (real-world guidance)
A practical list of things that are worth biometric-gated Keystore encryption:
✅ Refresh tokens / long-lived session tokens ✅ Locally cached PII (profile, IDs, private docs) ✅ User secrets (notes, keys, recovery codes) ✅ “Step-up sensitive actions” markers (e.g., user confirmed payment)
Things you typically don’t need to store locally: ❌ User password ❌ Raw biometric data (you never get it anyway)
Anti-patterns still common (and how attackers love them)
Anti-pattern #1 — “Decrypt everything after login”
Problem: decrypted data sits around, gets captured in memory or logs.
Fix: decrypt per-action, per-screen, per request.
Anti-pattern #2 — “Use biometric prompt, then read prefs”
Problem: biometric is UI only; secrets are not cryptographically bound to it.
Fix: biometric must unlock a CryptoObject.
Anti-pattern #3 — “Fallback to a simple app PIN”
Problem: attackers bypass the PIN UI, hook the method, brute force offline.
Fix: device credential fallback or re-auth.
Anti-pattern #4 — “Store IV incorrectly”
Problem: reuse IV with AES-GCM and you can break security.
Fix: always use cipher-generated IV per encryption and store it alongside ciphertext.
A minimal architecture that scales
If you want this to be clean in a real codebase, separate concerns:
KeystoreKeyProvider(creates/loads keys)BiometricCryptoGateway(runs BiometricPrompt + returns unlocked cipher)SecureStore(encrypt/decrypt + storage)SessionManager(business logic: tokens, logout, refresh)
Example interfaces
KOTLINinterface SecureStore {
suspend fun encrypt(activity: FragmentActivity, data: ByteArray): EncryptedBlob
suspend fun decrypt(activity: FragmentActivity, blob: EncryptedBlob): ByteArray
suspend fun clear()
}
That makes it testable:
- you can unit test storage formatting,
- instrument biometric flows separately,
- mock crypto for non-UI tests.
Testing: what Android devs forget
What you can unit test
- blob encoding/decoding
- IV storage rules
- error handling decisions
- key reset logic
What requires instrumentation
- biometric prompt flows
- strongbox availability
- key invalidation behavior
Practical instrumentation checklist
- test on a device with enrolled biometrics
- test “add a new fingerprint” → decrypt should fail if invalidation enabled
- test lockout (too many attempts) → verify fallback path
- test rotation after app reinstall (Keystore entries removed)
Security is a chain — don’t stop at Keystore
Keystore + Biometrics protects local secrets.
But if you’re defending against sophisticated threats, combine it with:
- Play Integrity signals (device/app integrity checks),
- server-side token binding / rotation,
- anomaly detection (new device, new geo, impossible travel),
- runtime protection against hooking/tampering (where appropriate).
Think of biometrics as one strong link in the chain, not “the security feature”.
Conclusion: what “good” looks like in 2025
If another Android developer reads your code and says:
- “Nice, biometrics actually unlock crypto, not UI”
- “Keys are non-exportable and hardware-backed when possible”
- “Data is encrypted with AES-GCM + IV is stored correctly”
- “Fallback doesn’t ruin the whole thing”
- “Invalidation is handled intentionally”
…you’ve written real security.
Not theater.
Appendix: Quick “copy/paste” snippets
Capability check
KOTLINwhen (BioCryptoPrompt.canAuthenticate(context)) {
BiometricManager.BIOMETRIC_SUCCESS -> { /* proceed */ }
BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> { /* prompt enrollment / fallback */ }
BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> { /* fallback */ }
else -> { /* fallback */ }
}
Suggested UI copy (human-friendly)
- Title: “Unlock secure data”
- Subtitle: “Confirm it’s you to continue”
- Failure: “We couldn’t verify you. Try again or use your device passcode.”

