A Modern Guide to Encrypted Datastore in Android using Kotlin Compose
A modern way to save the local user preferences using datastore and also encrypt what you are saving, you have a few small pieces of…
A Modern Guide to Encrypted Datastore in Android using Kotlin Compose

A modern way to save the local user preferences using datastore and also encrypt what you are saving, you have a few small pieces of sensitive user data — an auth token, a personalized preference, or a unique user identifier — that you need to persist locally. For years, SharedPreferenceswas our go-to. But as Android evolved, we moved to Jetpack DataStore for its reactive API and coroutine support.
What You’ll Learn
In this we’re going to build a production-grade, secure storage solution. We will combine the power of Jetpack DataStore with the Android KeyStore System and AES-256 encryption.
Adding Dependencies
In libs.versions.toml
[versions]
kotlin = "2.3.0"
kotlinx-serialization = "1.10.0"
datastorePreferences = "1.2.0"
[libraries]
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" }
[plugin]
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
In build.gradle.kts(:app)
plugins {
alias(libs.plugins.kotlin.serialization)
}
dependencies {
implementation(libs.kotlinx.serialization.json)
implementation(libs.androidx.datastore.preferences)
}
Note: Use the latest version of the dependencies and sync it.
The utility class Crypto
This object crypto will be a root for our encryption and decryption mechanism to implement all that logic in Crypto.kt.
// using this to store and retrieve the secret key from Android KeyStore
private const val KEY_ALIAS = "secret"
// cryptographic algorithm, a symmetric encryption algorithm
private const val ALGORITHM = KeyProperties.KEY_ALGORITHM_AES
// how the enryption works or how the algo processes the blocks of data
private const val BLOCK_MODE = KeyProperties.BLOCK_MODE_CBC
// if your final block of data is smaller than AES size blocks,
// so it adds the necessary bytes and ensure the padding can be removed during encryption
private const val PADDING = KeyProperties.ENCRYPTION_PADDING_PKCS7
// bundles the cryptographic config,
// it tells the Cipher exactly which "recipe" to use for encryption and decryption
private const val TRANSFORMATION = "$ALGORITHM/$BLOCK_MODE/$PADDING"
// cipher contains how we want to encrypt
private val cipher = Cipher.getInstance(TRANSFORMATION)
// place to store the cryptographic key
private val keystore = KeyStore
.getInstance("AndroidKeyStore")
.apply { load(null) }
Create key by the configuration we need to apply, for the first time if we need a key.
private fun createKey(): SecretKey {
return KeyGenerator
.getInstance(ALGORITHM)
.apply {
init(
KeyGenParameterSpec.Builder(
KEY_ALIAS,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
)
.setBlockModes(BLOCK_MODE)
.setEncryptionPaddings(PADDING)
.setRandomizedEncryptionRequired(true) // encrypted result will be different for every single time for a same key
.setUserAuthenticationRequired(false) // authentication required from the user to use the key
.build()
)
}
.generateKey()
}
Get key if there is an existing key and if there is no key then create a new key.
private fun getKey(): SecretKey {
val existingKey = keystore
.getEntry(KEY_ALIAS, null) as? KeyStore.SecretKeyEntry
return existingKey?.secretKey ?: createKey()
}
Encrypt and decrypt keys using these algorithms and methods.
fun encrypt(bytes: ByteArray): ByteArray {
cipher.init(Cipher.ENCRYPT_MODE, getKey())
val iv = cipher.iv // iv, initialization vector
val encrypted = cipher.doFinal(bytes)
return iv + encrypted // iv byteAray + encrypted byteArray
}
fun decrypt(bytes: ByteArray): ByteArray {
val iv = bytes.copyOfRange(0, cipher.blockSize)
val data = bytes.copyOfRange(cipher.blockSize, bytes.size)
cipher.init(Cipher.DECRYPT_MODE, getKey(), IvParameterSpec(iv))
return cipher.doFinal(data)
}
The token to be stored in data store
Here, we just store the token in data store.
@Serializable
data class UserPreferences(
val token: String? = null
)
The serializer from data store to serialize the UserPreferences.
object UserPreferencesSerializer : Serializer<UserPreferences> {}
Then override the values inside this UserPreferencesSerializer.
override val defaultValue: UserPreferences
get() = UserPreferences()
override suspend fun readFrom(input: InputStream): UserPreferences {
val encryptedBytes = withContext(Dispatchers.IO) {
input.use { it.readBytes() }
} // read the entire content bytes
val encryptedBytesDecoded = Base64.getDecoder().decode(encryptedBytes) // decode the bytes using Base64
val decryptedBytes = Crypto.decrypt(encryptedBytesDecoded) // decrypt the decoded encrypted bytes
val decodedJsonString = decryptedBytes.decodeToString() // decrypted bytes to string
return Json.decodeFromString(decodedJsonString) // decode to string
}
override suspend fun writeTo(t: UserPreferences, output: OutputStream) {
val json = Json.encodeToString(t) // encode the string
val bytes = json.toByteArray() // raw unencrypted bytes
val encryptedBytes = Crypto.encrypt(bytes) // encrypt the bytes
val encryptedBytesBase64 = Base64.getEncoder().encode(encryptedBytes) // using Base64 for encryption
withContext(Dispatchers.IO) {
output.use { it.write(encryptedBytesBase64) }
} // write the encrypted bytes
}
In MainActivity.kt
private val Context.dataStore by dataStore(
fileName = "user-preferences",
serializer = UserPreferencesSerializer
)
private const val SECRET_TOKEN = "Zoro Sanji"
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
EncryptDecryptTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
Column(
modifier = Modifier
.padding(innerPadding)
) {
val scope = rememberCoroutineScope()
var text by remember {
mutableStateOf("")
}
Text(text = text)
Button(onClick = {
scope.launch {
dataStore.updateData {
UserPreferences(token = SECRET_TOKEN)
}
}
}) { Text("Encrypt") }
Button(onClick = {
scope.launch {
text = dataStore.data.first().token ?: ""
}
}) { Text("Decrypt") }
}
}
}
}
}
}
Run this on an emulator or in a device and then open the device explorer to see the file system of our app, in that go to data → data → our app package name → files → datastore → user-preferences.
If you open the user-preferences file then we can see the encrypted data after we click on the Encrypt button and to see the original data click on the Decrypt button and we can see the actual data.
In Crypto.kt
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.IvParameterSpec
object Crypto {
private const val KEY_ALIAS = "secret"
private const val ALGORITHM = KeyProperties.KEY_ALGORITHM_AES
private const val BLOCK_MODE = KeyProperties.BLOCK_MODE_CBC
private const val PADDING = KeyProperties.ENCRYPTION_PADDING_PKCS7
private const val TRANSFORMATION = "$ALGORITHM/$BLOCK_MODE/$PADDING"
private val cipher = Cipher.getInstance(TRANSFORMATION)
private val keystore = KeyStore
.getInstance("AndroidKeyStore")
.apply {
load(null)
}
private fun getKey(): SecretKey {
val existingKey = keystore
.getEntry(KEY_ALIAS, null) as? KeyStore.SecretKeyEntry
return existingKey?.secretKey ?: createKey()
}
private fun createKey(): SecretKey {
return KeyGenerator
.getInstance(ALGORITHM)
.apply {
init(
KeyGenParameterSpec.Builder(
KEY_ALIAS,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
)
.setBlockModes(BLOCK_MODE)
.setEncryptionPaddings(PADDING)
.setRandomizedEncryptionRequired(true)
.setUserAuthenticationRequired(false)
.build()
)
}
.generateKey()
}
fun encrypt(bytes: ByteArray): ByteArray {
cipher.init(Cipher.ENCRYPT_MODE, getKey())
val iv = cipher.iv
val encrypted = cipher.doFinal(bytes)
return iv + encrypted
}
fun decrypt(bytes: ByteArray): ByteArray {
val iv = bytes.copyOfRange(0, cipher.blockSize)
val data = bytes.copyOfRange(cipher.blockSize, bytes.size)
cipher.init(Cipher.DECRYPT_MODE, getKey(), IvParameterSpec(iv))
return cipher.doFinal(data)
}
}
In UserPreferences.kt
import androidx.datastore.core.Serializer
import io.ktor.utils.io.core.toByteArray
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.io.InputStream
import java.io.OutputStream
import java.util.Base64
@Serializable
data class UserPreferences(
val token: String? = null
)
object UserPreferencesSerializer : Serializer<UserPreferences> {
override val defaultValue: UserPreferences
get() = UserPreferences()
override suspend fun readFrom(input: InputStream): UserPreferences {
val encryptedBytes = withContext(Dispatchers.IO) {
input.use { it.readBytes() }
}
val encryptedBytesDecoded = Base64.getDecoder().decode(encryptedBytes)
val decryptedBytes = Crypto.decrypt(encryptedBytesDecoded)
val decodedJsonString = decryptedBytes.decodeToString()
return Json.decodeFromString(decodedJsonString)
}
override suspend fun writeTo(t: UserPreferences, output: OutputStream) {
val json = Json.encodeToString(t)
val bytes = json.toByteArray()
val encryptedBytes = Crypto.encrypt(bytes)
val encryptedBytesBase64 = Base64.getEncoder().encode(encryptedBytes)
withContext(Dispatchers.IO) {
output.use {
it.write(encryptedBytesBase64)
}
}
}
}
- Leverage the Hardware-backed KeyStore to ensure encryption keys never leave the device’s secure environment.
- Implement AES-256 with Cipher Block Chaining (CBC) to prevent pattern recognition in encrypted data.
- Intercept the DataStore read/write cycle using a custom Serializer to make encryption completely transparent to the rest of the app.
Conclusion
Choosing Jetpack DataStore over SharedPreferences is a great first step toward a more reactive and robust Android architecture. However, in today’s security-conscious landscape, privacy is a non-negotiable feature. By combining DataStore with the Android KeyStore system, we’ve moved beyond simple persistence to a professional-grade security model. As you continue building, remember that security is not a “one-and-done” task — it is a continuous practice. This architecture provides a solid foundation that you can extend to encrypt even larger data sets or more complex user objects as your app grows.
메타데이터
- post_id
- 5d54b5b2c74a
- slug
- a-modern-guide-to-encrypted-datastore-in-android-using-kotlin-compose-5d54b5b2c74a
- url
- https://medium.com/@kabi20/a-modern-guide-to-encrypted-datastore-in-android-using-kotlin-compose-5d54b5b2c74a
- canonical_url
- https://medium.com/@kabi20/a-modern-guide-to-encrypted-datastore-in-android-using-kotlin-compose-5d54b5b2c74a
- author_url
- https://medium.com/@kabi20
- status
- ok
- fetched_at
- 2026-08-03 14:04:03