← Back to list

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…

Kabi · 2026-02-10 10:01 · 0 claps · 5.0 min read
#kotlin #jetpack-datastore #encryption #android-keystore #jetpack-compose
Open on Medium ↗
Wiki topics: PFI · Personal Finance 📱 · Mobile Development 🔒 · Cybersecurity

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.

Refer How to Encrypt DataStore On Android — Full Guide


메타데이터
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