{"id":167,"date":"2026-09-16T09:00:00","date_gmt":"2026-09-16T07:00:00","guid":{"rendered":"https:\/\/grindloop.io\/blog\/?p=167"},"modified":"2026-09-27T01:18:40","modified_gmt":"2026-09-26T23:18:40","slug":"encryptedsharedpreferences-keypermanentlyinvalidatedexception","status":"publish","type":"post","link":"https:\/\/grindloop.ai\/blog\/encryptedsharedpreferences-keypermanentlyinvalidatedexception\/","title":{"rendered":"Why EncryptedSharedPreferences Throws KeyPermanentlyInvalidatedException After a New Fingerprint"},"content":{"rendered":"<blockquote>\n<p>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 <code>onResume()<\/code> with a <code>KeyPermanentlyInvalidatedException<\/code> from <code>cipher.init()<\/code>. A teammate adds a <code>try<\/code>\/<code>catch<\/code>, and the crash stops. But the user still can&#8217;t sign in with biometrics. What&#8217;s wrong?<\/p>\n<\/blockquote>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"kotlin\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">class BiometricTokenVault(context: Context) {\n\n    private val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }\n\n    private val prefs: SharedPreferences by lazy {\n        val masterKey = MasterKey.Builder(context)\n            .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)\n            .build()\n\n        EncryptedSharedPreferences.create(\n            context,\n            \"vault_prefs\",\n            masterKey,\n            EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,\n            EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM\n        )\n    }\n\n    fun getInitializedCipherForDecryption(): Cipher {\n        val secretKey = keyStore.getKey(KEY_NAME, null) as SecretKey\n        val cipher = Cipher.getInstance(TRANSFORMATION)\n        val ivBytes = Base64.decode(prefs.getString(KEY_IV, null), Base64.DEFAULT)\n        cipher.init(Cipher.DECRYPT_MODE, secretKey, GCMParameterSpec(128, ivBytes))\n        return cipher\n    }\n\n    fun decryptToken(cipher: Cipher): String {\n        val encrypted = Base64.decode(prefs.getString(KEY_TOKEN, null), Base64.DEFAULT)\n        return String(cipher.doFinal(encrypted))\n    }\n\n    private fun getOrCreateSecretKey(): SecretKey {\n        (keyStore.getKey(KEY_NAME, null) as? SecretKey)?.let { return it }\n        val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE)\n        keyGenerator.init(\n            KeyGenParameterSpec.Builder(KEY_NAME, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)\n                .setBlockModes(KeyProperties.BLOCK_MODE_GCM)\n                .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)\n                .setUserAuthenticationRequired(true)\n                .setInvalidatedByBiometricEnrollment(true)\n                .build()\n        )\n        return keyGenerator.generateKey()\n    }\n\n    companion object {\n        private const val ANDROID_KEYSTORE = \"AndroidKeyStore\"\n        private const val KEY_NAME = \"vault_key\"\n        private const val TRANSFORMATION = \"AES\/GCM\/NoPadding\"\n        private const val KEY_IV = \"vault_iv\"\n        private const val KEY_TOKEN = \"vault_token\"\n    }\n}<\/pre>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"kotlin\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">override fun onResume() {\n    super.onResume()\n    val cipher = vault.getInitializedCipherForDecryption()   \/\/ crashes here\n    biometricPrompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher))\n}<\/pre>\n<p>This KeyPermanentlyInvalidatedException isn&#8217;t a bug in the decryption code. It&#8217;s documented Android Keystore behavior. The key was built with <code>setUserAuthenticationRequired(true)<\/code> and <code>setInvalidatedByBiometricEnrollment(true)<\/code>. A key like that is permanently invalidated when a new fingerprint is enrolled. After that, <code>cipher.init()<\/code> 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.<\/p>\n<h2 class=\"wp-block-heading\">A real crash in Google&#8217;s own sample<\/h2>\n<p>This exact crash was reported against Google&#8217;s <code>BiometricLoginSample<\/code> in a <a href=\"https:\/\/github.com\/android\/security-samples\/issues\/76\" target=\"_blank\" rel=\"noopener\">March 2021 GitHub issue<\/a>. 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 <code>KeyPermanentlyInvalidatedException: Key permanently invalidated<\/code>.<\/p>\n<h2 class=\"wp-block-heading\">Bug 1: nothing catches KeyPermanentlyInvalidatedException<\/h2>\n<p>Android&#8217;s reference page for <a href=\"https:\/\/developer.android.com\/reference\/android\/security\/keystore\/KeyPermanentlyInvalidatedException\" target=\"_blank\" rel=\"noopener\"><code>KeyPermanentlyInvalidatedException<\/code><\/a> describes when it fires. Keys that require user authentication for every use are permanently invalidated once a new fingerprint is enrolled. They&#8217;re also invalidated when no fingerprints remain.<\/p>\n<p>So this isn&#8217;t a device quirk. It&#8217;s the contract of the two builder flags. <code>cipher.init(Cipher.DECRYPT_MODE, ...)<\/code> throws the exception, a subclass of <code>InvalidKeyException<\/code>. The code calls it from <code>onResume()<\/code> with no <code>try<\/code>\/<code>catch<\/code>, so the app crashes on every launch.<\/p>\n<h2 class=\"wp-block-heading\">Bug 2: a catch alone still locks the user out<\/h2>\n<p>The usual first fix looks like this.<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"kotlin\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">\/\/ Common wrong answer: stops the crash, doesn't fix anything\nfun getInitializedCipherForDecryption(): Cipher? {\n    return try {\n        val secretKey = keyStore.getKey(KEY_NAME, null) as SecretKey\n        val cipher = Cipher.getInstance(TRANSFORMATION)\n        val ivBytes = Base64.decode(prefs.getString(KEY_IV, null), Base64.DEFAULT)\n        cipher.init(Cipher.DECRYPT_MODE, secretKey, GCMParameterSpec(128, ivBytes))\n        cipher\n    } catch (e: KeyPermanentlyInvalidatedException) {\n        Log.e(\"Vault\", \"Biometric key invalidated\", e)\n        null\n    }\n}<\/pre>\n<p>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. <code>getOrCreateSecretKey()<\/code> 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&#8217;t tell them why.<\/p>\n<h2 class=\"wp-block-heading\">The fix<\/h2>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"kotlin\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">fun getInitializedCipherForDecryption(): Cipher? {\n    return try {\n        val secretKey = keyStore.getKey(KEY_NAME, null) as SecretKey\n        val cipher = Cipher.getInstance(TRANSFORMATION)\n        val ivBytes = Base64.decode(prefs.getString(KEY_IV, null), Base64.DEFAULT)\n        cipher.init(Cipher.DECRYPT_MODE, secretKey, GCMParameterSpec(128, ivBytes))\n        cipher\n    } catch (e: KeyPermanentlyInvalidatedException) {\n        \/\/ The key is gone for good, so is anything encrypted with it.\n        \/\/ Clear both, then make the caller re-enroll the user from scratch.\n        keyStore.deleteEntry(KEY_NAME)\n        prefs.edit().remove(KEY_IV).remove(KEY_TOKEN).apply()\n        null\n    }\n}<\/pre>\n<p>The caller checks for <code>null<\/code> 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 <code>getOrCreateSecretKey()<\/code>. A freshly issued token gets encrypted with that key. The old ciphertext is unrecoverable by design. That&#8217;s the point of <code>setInvalidatedByBiometricEnrollment(true)<\/code>.<\/p>\n<h2 class=\"wp-block-heading\">The rule to remember<\/h2>\n<p><strong><code>KeyPermanentlyInvalidatedException<\/code> doesn&#8217;t mean retry. It means the key and everything encrypted with it are gone. Delete both, then re-enroll.<\/strong><\/p>\n<ul>\n<li>Keys built with <code>setUserAuthenticationRequired(true)<\/code> and <code>setInvalidatedByBiometricEnrollment(true)<\/code> die when biometric enrollment changes. That&#8217;s a security feature.<\/li>\n<li>On catch, delete the Keystore entry and the stored ciphertext in the same recovery path.<\/li>\n<li>Android&#8217;s reference docs now mark <a href=\"https:\/\/developer.android.com\/reference\/androidx\/security\/crypto\/EncryptedSharedPreferences\" target=\"_blank\" rel=\"noopener\"><code>EncryptedSharedPreferences<\/code><\/a> deprecated and point to plain <code>SharedPreferences<\/code>. Mention that if asked what you&#8217;d use on a new project. It doesn&#8217;t change how this bug behaves in shipped code.<\/li>\n<\/ul>\n<h2 class=\"wp-block-heading\">How to answer this in an interview<\/h2>\n<ol>\n<li>Say this exception on <code>cipher.init()<\/code> is documented Keystore behavior tied to biometric enrollment changes, not a flaky device.<\/li>\n<li>Name both bugs. Nothing catches the exception. A plain catch doesn&#8217;t clear the dead key and ciphertext.<\/li>\n<li>Describe the fix as data-loss recovery. Delete the key and the data, then re-enroll the user.<\/li>\n<\/ol>\n<p><strong>Common wrong answers:<\/strong><\/p>\n<ul>\n<li>Catching <code>GeneralSecurityException<\/code> and treating every failure the same. Some are retryable. This one never is.<\/li>\n<li>Deleting the Keystore entry but leaving the stale ciphertext. The next read fails with a different decryption error.<\/li>\n<li>Assuming this only affects fingerprint unlock screens. Any key with <code>setUserAuthenticationRequired(true)<\/code> can hit it, including keys that protect payment or refresh tokens.<\/li>\n<\/ul>\n<hr\/>\n<p><em>Related: <a href=\"https:\/\/grindloop.ai\/blog\/compose-null-check-crash\/\">the Compose null check that didn&#8217;t save you<\/a>, another bug with two stacked causes. GrindLoop&#8217;s Bug-Squash track turns failure patterns like this one into live debugging drills. Each drill comes with a reviewed fix.<\/em><\/p>\n<p><strong>Failed the interview? Not the next one.<\/strong><\/p>\n","protected":false},"excerpt":{"rendered":"<p>A KeyPermanentlyInvalidatedException after a new fingerprint is documented Keystore behavior. Why a plain catch still locks users out, the recovery that deletes the key and data, and the interview answer.<\/p>\n","protected":false},"author":2,"featured_media":690,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"EncryptedSharedPreferences KeyPermanentlyInvalidatedException","rank_math_description":"A new fingerprint permanently invalidates a biometric-gated Keystore key. Why catching KeyPermanentlyInvalidatedException isn't enough, and the full recovery.","rank_math_focus_keyword":"KeyPermanentlyInvalidatedException","footnotes":""},"categories":[9,5],"tags":[15,60,61,16,59,58,45],"class_list":["post-167","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-bug-squash","category-security","tag-android","tag-biometric","tag-encryptedsharedpreferences","tag-interview-prep","tag-keystore","tag-security","tag-technical-interview"],"_links":{"self":[{"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/posts\/167","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/comments?post=167"}],"version-history":[{"count":12,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/posts\/167\/revisions"}],"predecessor-version":[{"id":689,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/posts\/167\/revisions\/689"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/media\/690"}],"wp:attachment":[{"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/media?parent=167"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/categories?post=167"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/tags?post=167"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}