EncryptedSharedPreferences Is Dead: What to Use for Secure Storage in 2026
Google deprecated the library most Android security tutorials still teach. Here’s the replacement pattern, a migration path, and where…
EncryptedSharedPreferences Is Dead: What to Use for Secure Storage in 2026

Google deprecated the library most Android security tutorials still teach. Here’s the replacement pattern, a migration path, and where SQLCipher fits in.
In the previous article in this series, we built a hardware-backed, biometric-gated key using the Android Keystore. The obvious next question is: where do you actually put your encrypted data? For years, the textbook answer was androidx.security.crypto.EncryptedSharedPreferences. If you search for "Android secure storage" today, that's still what most results tell you to use.
It’s deprecated. Has been for a while now. And if your banking, fintech, or healthcare app is still built on it, you’re maintaining a dependency Google has stopped evolving — with known rough edges around multi-process access and key rotation that never got fixed before the library was frozen.
This article covers what to replace it with, how to migrate existing users’ data without losing it, and how the same pattern extends to encrypting an entire local database with SQLCipher — something every banking app ends up needing once it starts caching transaction history offline.
Why EncryptedSharedPreferences Got Deprecated
androidx.security:security-crypto never left alpha in its 1.1.x line, and Google eventually deprecated EncryptedSharedPreferences and EncryptedFile outright rather than promoting them to stable. The library wrapped SharedPreferences with AES-SIV key encryption and AES-GCM value encryption, backed by a Keystore master key — a reasonable design in principle. In practice, teams ran into the same handful of problems:
- No first-class multi-process support. Regular
SharedPreferenceshadMODE_MULTI_PROCESS, itself deprecated and unreliable;EncryptedSharedPreferencesinherited the same limitation without a real answer for apps that touch shared prefs from more than one process (common in banking apps with a separate process for push notification handling). - Corruption is unrecoverable by design. If the underlying XML file gets corrupted — a killed process mid-write, a flaky OEM filesystem — there’s no partial recovery. The whole file becomes unreadable, and because it’s encrypted, you can’t even inspect what’s left. Apps I’ve seen hit this in production had exactly one option: wipe local state and force re-authentication.
- The library stopped evolving. No StrongBox-aware improvements, no structured migration tooling, no updates tracking newer Keystore capabilities like
setUserAuthenticationParameters. It solved 2019's problems with 2019's APIs, frozen.
Google’s own guidance now points developers toward one of two paths: use the Tink cryptography library directly for a well-audited, higher-level API, or build directly on Android Keystore primitives (what we did in the previous article) if you need full control. For most apps, Tink is the better trade-off — it’s the same library Google uses internally, it handles key versioning and rotation correctly, and it removes an entire class of “did I pick the right cipher mode” mistakes.
The Replacement Pattern: Tink + DataStore
The modern equivalent has two parts: Tink for the encryption primitive, and Jetpack DataStore instead of raw SharedPreferences for the storage layer (DataStore is itself the general replacement for SharedPreferences, encrypted or not — it's asynchronous by default, transactional, and doesn't block the main thread on first read the way SharedPreferences historically did).
Add the dependencies:
dependencies {
implementation("com.google.crypto.tink:tink-android:1.23.0") // check for the latest version
implementation("androidx.datastore:datastore-preferences:1.2.1")
}
Tink’s AndroidKeysetManager generates a keyset, wraps (encrypts) it with a Keystore-backed master key, and persists the wrapped keyset in a small, ordinary SharedPreferences file. This is safe — the keyset on disk is ciphertext, unreadable without the Keystore master key, which never leaves hardware. Your actual secret values then get encrypted with the primitive derived from that keyset and stored wherever you like — here, in a Preferences DataStore.
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.google.crypto.tink.Aead
import com.google.crypto.tink.aead.AeadConfig
import com.google.crypto.tink.aead.AesGcmKeyManager
import com.google.crypto.tink.integration.android.AndroidKeysetManager
import kotlinx.coroutines.flow.first
private val Context.secureDataStore by preferencesDataStore(name = "secure_prefs")
class SecureStore(private val context: Context) {
private val aead: Aead by lazy {
AeadConfig.register()
val keysetHandle = AndroidKeysetManager.Builder()
.withSharedPref(context, "master_keyset", "master_key_preference")
.withKeyTemplate(AesGcmKeyManager.aes256GcmTemplate())
.withMasterKeyUri("android-keystore://secure_store_master_key")
.build()
.keysetHandle
keysetHandle.getPrimitive(Aead::class.java)
}
suspend fun putSecret(key: String, value: String) {
val ciphertext = aead.encrypt(value.toByteArray(Charsets.UTF_8), key.toByteArray())
context.secureDataStore.edit { prefs ->
prefs[stringPreferencesKey(key)] = Base64.encodeToString(ciphertext, Base64.NO_WRAP)
}
}
suspend fun getSecret(key: String): String? {
val encoded = context.secureDataStore.data.first()[stringPreferencesKey(key)] ?: return null
val ciphertext = Base64.decode(encoded, Base64.NO_WRAP)
return String(aead.decrypt(ciphertext, key.toByteArray()), Charsets.UTF_8)
}
}
Notice the second argument to aead.encrypt() and aead.decrypt() — the preference key name itself, passed as associated data (AAD). AEAD ciphers let you bind ciphertext to a piece of context that isn't itself encrypted but must match on decryption. Using the storage key as AAD means a ciphertext blob stored under "session_token" will fail to decrypt if an attacker (or a bug) moves it to a different key like "refresh_token" — it cryptographically binds each secret to its slot. This is a detail that's easy to skip and genuinely matters once you're storing several distinct secrets in the same store.
Migrating Existing Users Off EncryptedSharedPreferences
This is the part most tutorials skip, and it’s the part that actually matters if you have real users with real data already encrypted under the old scheme. You cannot just switch libraries — you have to read out everything under the deprecated API and re-encrypt it under the new one, exactly once, on app upgrade.
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
suspend fun migrateFromLegacyEncryptedPrefs(context: Context, secureStore: SecureStore) {
val migrationFlagKey = booleanPreferencesKey("legacy_migration_done")
val alreadyMigrated = context.secureDataStore.data.first()[migrationFlagKey] ?: false
if (alreadyMigrated) return
val legacyPrefsName = "secret_shared_prefs"
@Suppress("DEPRECATION")
val legacyPrefs = EncryptedSharedPreferences.create(
context,
legacyPrefsName,
MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build(),
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
legacyPrefs.all.forEach { (key, value) ->
if (value is String) secureStore.putSecret(key, value)
}
context.secureDataStore.edit { it[migrationFlagKey] = true }
context.deleteSharedPreferences(legacyPrefsName)
}
Three things about this migration that are easy to get wrong:
- It must be idempotent. If the process dies between re-encrypting the data and setting the migration flag, the app will retry on next launch — make sure re-running it doesn’t duplicate or corrupt anything. Setting the flag as the very last step (after all writes succeed) is what makes retries safe here.
- Suppress the deprecation warning explicitly, don’t silence it globally. You still need the old API to read legacy data during the transition window. A scoped
@Suppress("DEPRECATION")on the migration function documents that this is intentional and temporary, rather than hiding future deprecation warnings elsewhere in the codebase. - Delete the legacy file after migrating, not before — if migration fails partway, you want the old data still recoverable on the next attempt.
Encrypting an Entire Local Database with SQLCipher
Key-value storage covers tokens, PINs, and small secrets — but banking apps almost always end up caching structured data locally too: transaction history for offline viewing, account summaries, statements. For that, you want an encrypted database, not encrypted preferences. SQLCipher is the standard choice, and it plugs directly into Room via a SupportFactory.
dependencies {
implementation("net.zetetic:android-database-sqlcipher:4.5.4") // check for the latest version
implementation("androidx.sqlite:sqlite:2.6.2")
}
The critical design decision: the database passphrase itself is a secret, and it needs the same Keystore-backed protection as everything else in this article — never hardcode it, never derive it from something predictable like a device ID. Generate it randomly once, and store it using the SecureStore we already built:
import net.sqlcipher.database.SQLiteDatabase
import net.sqlcipher.database.SupportFactory
import java.security.SecureRandom
class TransactionDatabaseProvider(
private val context: Context,
private val secureStore: SecureStore
) {
suspend fun openDatabase(): TransactionDatabase {
val passphrase = secureStore.getSecret(DB_KEY_ALIAS) ?: generateAndStoreNewKey()
val factory = SupportFactory(SQLiteDatabase.getBytes(passphrase.toCharArray()))
return Room.databaseBuilder(context, TransactionDatabase::class.java, "transactions.db")
.openHelperFactory(factory)
.build()
}
private suspend fun generateAndStoreNewKey(): String {
val keyBytes = ByteArray(32).also { SecureRandom().nextBytes(it) }
val key = Base64.encodeToString(keyBytes, Base64.NO_WRAP)
secureStore.putSecret(DB_KEY_ALIAS, key)
return key
}
companion object {
private const val DB_KEY_ALIAS = "transactions_db_passphrase"
}
}
This is the pattern worth internalizing: Keystore protects the Tink master key, Tink protects the database passphrase, SQLCipher uses that passphrase to encrypt the actual data. Each layer only ever hands the next layer a secret it can protect — nothing sensitive is ever hardcoded or derivable from public device state.
What Encryption Actually Costs You
SQLCipher encrypts at the page level (AES-256 by default), which means every read and write pays some CPU cost. It’s a legitimate question for a banking app that might be paginating through hundreds of cached transactions — you don’t want to trade security for a janky scroll.
The honest answer is: measure it yourself, on your actual data shape and target devices, rather than trusting a number from someone else’s app. A minimal harness:
suspend fun benchmarkInsert(db: TransactionDatabase, rows: List<TransactionEntity>): Long {
val start = System.nanoTime()
db.transactionDao().insertAll(rows)
return (System.nanoTime() - start) / 1_000_000 // ms
}
Run the same benchmark against an unencrypted Room database and an SQLCipher-backed one, on your minimum-spec target device, with a realistic row count and query pattern — the overhead is real but page-level AES-NI-accelerated encryption on modern ARM hardware is not the bottleneck it was a decade ago. In my experience, it’s noticeable in raw micro-benchmarks and essentially invisible in the actual UI once you’re paginating and using background threads correctly, which you should be doing regardless of encryption.
Common Pitfalls
- Don’t wrap SQLCipher’s passphrase in
EncryptedSharedPreferencesjust because it's "already encrypted storage" — that's the exact library this article is telling you to move off of. Use the Tink-backed store instead. - DataStore’s multi-process support isn’t automatic the way
SharedPreferencesused to (unreliably) offer it. If your app touches this store from more than one process, verify you're on a DataStore version with proper multi-process support and test it explicitly — don't assume. - Keep ProGuard/R8 rules for Tink. It uses reflection internally for some primitive registration. Stripping without the right keep rules produces
ClassNotFoundExceptioncrashes that only show up in release builds, usually discovered by users, not QA. - Plan for key rotation from day one, even if you don’t implement it immediately. Tink’s keyset format supports multiple keys with a designated primary, which makes rotating your master key later a matter of adding a new key to the set rather than a painful re-encryption migration. Retrofitting rotation support after launch is much harder than including the hook now.
Testing Notes
Same constraint as the previous article: anything touching the real Android Keystore needs an instrumented test on a device or emulator — Robolectric can’t fake hardware-backed key wrapping. Write an explicit test for the migration function that seeds a legacy EncryptedSharedPreferences file, runs the migration, and asserts both that the new store has the right values and that re-running the migration a second time is a no-op.
What’s Next
We now have a solid foundation: hardware-backed keys, and a modern pattern for encrypting everything from single tokens to entire local databases. The next article puts this key material to work at the moment it matters most in a banking app — confirming a payment. We’ll go deep on BiometricPrompt and CryptoObject, and the difference between biometrics that merely gate a UI screen and biometrics that cryptographically authorize a specific transaction.
메타데이터
- post_id
- 8debc4dbfa9c
- slug
- encryptedsharedpreferences-is-dead-what-to-use-for-secure-storage-in-2026-8debc4dbfa9c
- url
- https://medium.com/@legenda4250/encryptedsharedpreferences-is-dead-what-to-use-for-secure-storage-in-2026-8debc4dbfa9c
- canonical_url
- https://medium.com/@legenda4250/encryptedsharedpreferences-is-dead-what-to-use-for-secure-storage-in-2026-8debc4dbfa9c
- author_url
- https://medium.com/@legenda4250
- status
- ok
- fetched_at
- 2026-08-21 01:45:42