Back to blog
security for android
SecurityJan 02, 20268 min read

Advanced Biometric Cryptography & Android Keystore (2025): Build Real Security, Not a Login Screen

Biometrics aren’t a fancy UI step — they’re an operating-system enforced gate to hardware-backed cryptography. This deep dive shows how to design biometric-protected encryption the way banking apps do it: Keystore keys, CryptoObject, AES-GCM, invalidation rules, and hardened fallback flows.

- Android Security - BiometricPrompt - Android Keystore - Cryptography - AES-GCM - StrongBox - Threat Modeling

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:

  1. Encrypt secrets at rest (tokens, private notes, cached PII).
  2. Keys are hardware-backed when possible (StrongBox if available).
  3. Keys are biometric-bound (or device credential fallback where required).
  4. Decryption is scoped and ephemeral (decrypt only what you need, for the shortest time).
  5. Keys are invalidated on biometric enrollment changes (when it makes sense).
  6. 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:

  1. Create or retrieve a Keystore key
  2. Initialize Cipher in ENCRYPT_MODE or DECRYPT_MODE
  3. Pass it into BiometricPrompt.CryptoObject(cipher)
  4. The Cipher becomes 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

KOTLIN
dependencies {
  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-crypto is 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

KOTLIN
import 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.

KOTLIN
import 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.
KOTLIN
import 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.
KOTLIN
import 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)

KOTLIN
suspend 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.

KOTLIN
suspend 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 ByteArray for secrets,
  • overwrite arrays when done.
KOTLIN
fun ByteArray.wipe() {
  fill(0)
}

Use it right after you’re done:

KOTLIN
val 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)

KOTLIN
val 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.
KOTLIN
fun 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

KOTLIN
interface 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

KOTLIN
when (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.”