Why EncryptedSharedPreferences Throws KeyPermanentlyInvalidatedException After a New Fingerprint

An app stores a login token encrypted with a biometric-bound Keystore key. Everything works until a user adds a new fingerprint. On the next launch, the app crashes in onResume() with a KeyPermanentlyInvalidatedException from cipher.init(). A teammate adds a try/catch, and the crash stops. But the user still can’t sign in with biometrics. What’s wrong?

class BiometricTokenVault(context: Context) {

    private val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }

    private val prefs: SharedPreferences by lazy {
        val masterKey = MasterKey.Builder(context)
            .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
            .build()

        EncryptedSharedPreferences.create(
            context,
            "vault_prefs",
            masterKey,
            EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
            EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
        )
    }

    fun getInitializedCipherForDecryption(): Cipher {
        val secretKey = keyStore.getKey(KEY_NAME, null) as SecretKey
        val cipher = Cipher.getInstance(TRANSFORMATION)
        val ivBytes = Base64.decode(prefs.getString(KEY_IV, null), Base64.DEFAULT)
        cipher.init(Cipher.DECRYPT_MODE, secretKey, GCMParameterSpec(128, ivBytes))
        return cipher
    }

    fun decryptToken(cipher: Cipher): String {
        val encrypted = Base64.decode(prefs.getString(KEY_TOKEN, null), Base64.DEFAULT)
        return String(cipher.doFinal(encrypted))
    }

    private fun getOrCreateSecretKey(): SecretKey {
        (keyStore.getKey(KEY_NAME, null) as? SecretKey)?.let { return it }
        val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE)
        keyGenerator.init(
            KeyGenParameterSpec.Builder(KEY_NAME, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
                .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
                .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
                .setUserAuthenticationRequired(true)
                .setInvalidatedByBiometricEnrollment(true)
                .build()
        )
        return keyGenerator.generateKey()
    }

    companion object {
        private const val ANDROID_KEYSTORE = "AndroidKeyStore"
        private const val KEY_NAME = "vault_key"
        private const val TRANSFORMATION = "AES/GCM/NoPadding"
        private const val KEY_IV = "vault_iv"
        private const val KEY_TOKEN = "vault_token"
    }
}
override fun onResume() {
    super.onResume()
    val cipher = vault.getInitializedCipherForDecryption()   // crashes here
    biometricPrompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher))
}

This KeyPermanentlyInvalidatedException isn’t a bug in the decryption code. It’s documented Android Keystore behavior. The key was built with setUserAuthenticationRequired(true) and setInvalidatedByBiometricEnrollment(true). A key like that is permanently invalidated when a new fingerprint is enrolled. After that, cipher.init() throws instead of returning a cipher. There are two bugs. First, nothing catches the exception, so the app crashes before the biometric prompt appears. Second, the common fix only catches it. The dead key alias and the token encrypted with it are still stored. No future key can decrypt that token, so the user stays locked out. The real fix treats the exception as data loss. Delete the Keystore entry and the stored ciphertext together. Then have the user sign in again and encrypt a fresh token with a new key.

A real crash in Google’s own sample

This exact crash was reported against Google’s BiometricLoginSample in a March 2021 GitHub issue. The reporter signed in with a fingerprint, then added a new fingerprint to the device. After that, they could no longer log in. The stack trace shows KeyPermanentlyInvalidatedException: Key permanently invalidated.

Bug 1: nothing catches KeyPermanentlyInvalidatedException

Android’s reference page for KeyPermanentlyInvalidatedException describes when it fires. Keys that require user authentication for every use are permanently invalidated once a new fingerprint is enrolled. They’re also invalidated when no fingerprints remain.

So this isn’t a device quirk. It’s the contract of the two builder flags. cipher.init(Cipher.DECRYPT_MODE, ...) throws the exception, a subclass of InvalidKeyException. The code calls it from onResume() with no try/catch, so the app crashes on every launch.

Bug 2: a catch alone still locks the user out

The usual first fix looks like this.

// Common wrong answer: stops the crash, doesn't fix anything
fun getInitializedCipherForDecryption(): Cipher? {
    return try {
        val secretKey = keyStore.getKey(KEY_NAME, null) as SecretKey
        val cipher = Cipher.getInstance(TRANSFORMATION)
        val ivBytes = Base64.decode(prefs.getString(KEY_IV, null), Base64.DEFAULT)
        cipher.init(Cipher.DECRYPT_MODE, secretKey, GCMParameterSpec(128, ivBytes))
        cipher
    } catch (e: KeyPermanentlyInvalidatedException) {
        Log.e("Vault", "Biometric key invalidated", e)
        null
    }
}

The crash stops. But the invalidated key is still in the Keystore under the same alias. Every future call finds it and fails the same way. getOrCreateSecretKey() also finds it and never makes a new key. The token was encrypted with that dead key, so nothing can ever decrypt it. The user is locked out. The app doesn’t tell them why.

The fix

fun getInitializedCipherForDecryption(): Cipher? {
    return try {
        val secretKey = keyStore.getKey(KEY_NAME, null) as SecretKey
        val cipher = Cipher.getInstance(TRANSFORMATION)
        val ivBytes = Base64.decode(prefs.getString(KEY_IV, null), Base64.DEFAULT)
        cipher.init(Cipher.DECRYPT_MODE, secretKey, GCMParameterSpec(128, ivBytes))
        cipher
    } catch (e: KeyPermanentlyInvalidatedException) {
        // The key is gone for good, so is anything encrypted with it.
        // Clear both, then make the caller re-enroll the user from scratch.
        keyStore.deleteEntry(KEY_NAME)
        prefs.edit().remove(KEY_IV).remove(KEY_TOKEN).apply()
        null
    }
}

The caller checks for null and tells the user their biometric sign-in needs to be set up again. Then it signs them in another way and generates a new key with getOrCreateSecretKey(). A freshly issued token gets encrypted with that key. The old ciphertext is unrecoverable by design. That’s the point of setInvalidatedByBiometricEnrollment(true).

The rule to remember

KeyPermanentlyInvalidatedException doesn’t mean retry. It means the key and everything encrypted with it are gone. Delete both, then re-enroll.

  • Keys built with setUserAuthenticationRequired(true) and setInvalidatedByBiometricEnrollment(true) die when biometric enrollment changes. That’s a security feature.
  • On catch, delete the Keystore entry and the stored ciphertext in the same recovery path.
  • Android’s reference docs now mark EncryptedSharedPreferences deprecated and point to plain SharedPreferences. Mention that if asked what you’d use on a new project. It doesn’t change how this bug behaves in shipped code.

How to answer this in an interview

  1. Say this exception on cipher.init() is documented Keystore behavior tied to biometric enrollment changes, not a flaky device.
  2. Name both bugs. Nothing catches the exception. A plain catch doesn’t clear the dead key and ciphertext.
  3. Describe the fix as data-loss recovery. Delete the key and the data, then re-enroll the user.

Common wrong answers:

  • Catching GeneralSecurityException and treating every failure the same. Some are retryable. This one never is.
  • Deleting the Keystore entry but leaving the stale ciphertext. The next read fails with a different decryption error.
  • Assuming this only affects fingerprint unlock screens. Any key with setUserAuthenticationRequired(true) can hit it, including keys that protect payment or refresh tokens.

Related: the Compose null check that didn’t save you, another bug with two stacked causes. GrindLoop’s Bug-Squash track turns failure patterns like this one into live debugging drills. Each drill comes with a reviewed fix.

Failed the interview? Not the next one.

Leave a Comment