Finally The Official Way of Secure DataStore is Here!

Securing data has always been a concern since the early days of app development. Platforms do their best to protect user data, but there are always edge cases. Attackers may still reach sensitive data, even in encrypted form, and if they get the key, the protection is meaningless. So yes, encryption helps, but it is not magic.

On Android we started with SharedPreferences. It was simple, widely used, and even had built in encryption through EncryptedSharedPreferences. Then Kotlin Flow and Coroutines arrived, apps reached a global audience, and codebases grew. We now needed async operations, thread safety, type safety, custom objects, proper error handling and reactive streams. SharedPreferences was not designed for that, and DataStore was introduced to solve it. But Google deprecated the Jetpack Security (security-crypto) library, and DataStore had no built in encryption, so most of us wrote our own.

Now there is androidx.datastore:datastore-tink, added in DataStore 1.3.0-alpha07. In this post we build a small encrypted session store: a login screen that saves an email and a token, and a home screen that reads them. We also show what happens when decryption fails.

The full sample project (with Hilt) is on GitHub: burkido/official-datastore-encryption-sample

What encryption protects, and what it does not

Encrypting a file protects it when someone gets the file but not your app, for example from a rooted phone, a raw copy of the device storage or a copied backup. It does not protect against code inside your own app, because that code can simply read the decrypted value from DataStore.

Encryption is one layer of security. It is not the whole solution.

How we did it before

Our old setup was similar to the one in this article. A security class created an AES/GCM key in the Android Keystore and managed the IV manually:

fun encryptData(keyAlias: String, text: String): Pair<ByteArray, ByteArray> {
val secretKey = generateSecretKey(keyAlias)
cipher.init(Cipher.ENCRYPT_MODE, secretKey)
val encryptedData = cipher.doFinal(text.toByteArray(charset))
return Pair(cipher.iv, encryptedData)
}

Every write also converted the value to JSON, encrypted it, and stored the IV and the ciphertext together in one string with a custom separator:

suspend fun <T> putSecurePreference(key: Preferences.Key<String>, value: T) {
dataStore.edit { preferences ->
val serializedInput = gson.toJson(value)
val (iv, secureByteArray) = securityUtil.encryptData(keyAlias, serializedInput)
preferences[key] = iv.joinToString("|") + ":iv:" + secureByteArray.joinToString("|")
}
}

It worked, but we had to maintain the separator format, @Synchronized around the Keystore calls, and a try/catch for every possible error. Each project had its own, different version of this code. Now let us look at the new way.

Step 1: Add the dependencies

In libs.versions.toml:

[versions]
datastore = "1.3.0-alpha11"
tink = "1.15.0"
kotlinxSerializationJson = "1.9.0"

[libraries]
datastore = { group = "androidx.datastore", name = "datastore", version.ref = "datastore" }
datastore-tink = { group = "androidx.datastore", name = "datastore-tink", version.ref = "datastore" }
tink-android = { group = "com.google.crypto.tink", name = "tink-android", version.ref = "tink" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }

[plugins]
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }

And in the app’s build.gradle.kts:

plugins {
alias(libs.plugins.kotlin.serialization)
}

dependencies {
implementation(libs.datastore)
implementation(libs.datastore.tink)
implementation(libs.tink.android)
implementation(libs.kotlinx.serialization.json)
}

Note: datastore-tink is still in alpha. We think it is fine for a sample and for session data, because the user can always log in again. For data that cannot be regenerated, we would be more careful.

Step 2: Write a normal serializer

This is the nice part: there is no encryption code here. You write a normal serializer for your data class, the same as for an unencrypted typed DataStore.

@Serializable
data class SessionData(
val email: String = "",
val token: String = ""
)
object SessionDataSerializer : Serializer<SessionData> {

override val defaultValue: SessionData = SessionData()

override suspend fun readFrom(input: InputStream): SessionData {
return try {
Json.decodeFromString(SessionData.serializer(), input.readBytes().decodeToString())
} catch (e: SerializationException) {
throw CorruptionException("Unable to deserialize SessionData", e)
}
}

override suspend fun writeTo(t: SessionData, output: OutputStream) {
output.write(Json.encodeToString(SessionData.serializer(), t).encodeToByteArray())
}
}

Bad input becomes a CorruptionException. Remember this, because it is important later.

Step 3: Create an Aead with Tink

An Aead is Tink’s name for an object that encrypts and decrypts data and also checks that nobody changed it. We get it from a keyset, and the keyset is protected by a master key in the Android Keystore.

The sample uses Hilt, so this goes into a module:

private const val SESSION_KEYSET_NAME = "session_keyset"
private const val SESSION_KEYSET_PREFS_FILE = "session_keyset_prefs"
private const val MASTER_KEY_URI = "android-keystore://master_key"

@Module
@InstallIn(SingletonComponent::class)
object AeadModule {

@Provides
@Singleton
fun provideAead(@ApplicationContext context: Context): Aead {
AeadConfig.register()
return AndroidKeysetManager.Builder()
.withSharedPref(context, SESSION_KEYSET_NAME, SESSION_KEYSET_PREFS_FILE)
.withKeyTemplate(KeyTemplate.createFrom(PredefinedAeadParameters.AES256_GCM))
.withMasterKeyUri(MASTER_KEY_URI)
.build()
.keysetHandle
.getPrimitive(RegistryConfiguration.get(), Aead::class.java)
}
}

What this code does:

  • AeadConfig.register() registers the AEAD key types in Tink. Without it, getPrimitive throws an exception.
  • On the first launch, AndroidKeysetManager creates a new AES256-GCM keyset, encrypts it with the Keystore master key, and saves it in a SharedPreferences file called session_keyset_prefs. On later launches it only reads the saved keyset.
  • The keyset is saved in SharedPreferences, not in the Keystore. Only the master key is in the Keystore. This is important for the backup section.

Step 4: Wrap the serializer and build the DataStore

Now we wrap the normal serializer with AeadSerializer. For DataStore it is just another serializer, but it encrypts on write and decrypts on read.

private const val SESSION_DATASTORE_FILE = "session.json"

@Module
@InstallIn(SingletonComponent::class)
object DataStoreModule {

@Singleton
@Provides
fun provideSessionDataStore(
@ApplicationContext context: Context,
@ApplicationScope scope: CoroutineScope,
aead: Aead
): DataStore<SessionData> = DataStore.Builder(
storage = FileStorage(
serializer = AeadSerializer(
aead = aead,
wrappedSerializer = SessionDataSerializer,
associatedData = SESSION_DATASTORE_FILE.encodeToByteArray()
),
produceFile = { context.dataStoreFile(SESSION_DATASTORE_FILE) }
),
context = scope.coroutineContext
)
.setCorruptionHandler(ReplaceFileCorruptionHandler { SessionData() })
.build()
}

Look at the associatedData parameter. It is not encrypted, but it is part of the authentication check. If someone copies the encrypted bytes from session.json into another DataStore file with different associated data, decryption fails, so the app never loads data from the wrong file. Using the file name is a simple way to connect each file’s ciphertext to that file.

Two more notes:

  • @Singleton is required. DataStore does not allow two active instances for the same file. If you create a second one by mistake, you get an exception.
  • @ApplicationScope is a CoroutineScope(SupervisorJob() + Dispatchers.IO) from another small module in the repo. This way DataStore lives as long as the app.

We explain the corruption handler a bit later.

Step 5: Use it

The rest is normal DataStore code. A small repository:

@Singleton
class SessionDataStore @Inject constructor(
private val dataStore: DataStore<SessionData>
) : SessionRepository {

override val sessionFlow: Flow<SessionData> = dataStore.data

override suspend fun saveSession(email: String, token: String) {
dataStore.updateData { SessionData(email = email, token = token) }
}

override suspend fun clearSession() {
dataStore.updateData { SessionData() }
}
}

The ViewModel maps it to UI state:

val sessionState: StateFlow<SessionState> = sessionRepository.sessionFlow
.map { session ->
if (session.token.isNotEmpty()) {
SessionState.LoggedIn(email = session.email, token = session.token)
} else {
SessionState.LoggedOut
}
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = SessionState.Loading
)

The ViewModel and the UI do not know that the data is encrypted, and this is how it should be.

The sample app. Left: the login screen. Right: the home screen, which reads the session from the encrypted DataStore.

AI agents know the adb really well so we can check and make sure the file is really encrypted. I just want to trust them sometimes 😀
We read it from the emulator after logging in:

adb exec-out run-as com.example.encryptiondatastore 
cat files/datastore/session.json | xxd
00000000: 0191 c2ce d203 1ddb a463 3ca2 f6ee dc8a  .........c<.....
00000010: b6d3 066d e2ec a73e 8b66 3b5f bc0a b3b8 ...m...>.f;_....
00000020: cc4c 8960 48d0 b554 57b9 da6c 984e 8067 .L.`H..TW..l.N.g
00000030: a1cd 178b 97c7 b528 b7de b704 c267 8601 .......(.....g..
00000040: ce6d 658f 5ca2 ea79 496f 3d8d d7c2 7b20 .me...yIo=...{
00000050: b841 d555 710c 9c35 b93a a681 e614 7a7c .A.Uq..5.:....z|
00000060: 737a c18c sz..

Agent result is: The file is 100 bytes of ciphertext. There is no email, no token, not even a JSON bracket.

When things go wrong

If it’s a brand new project, that’s fine. But most existing apps can run into a couple of issues during migration.

You often read that datastore-tink removes all the try/catch code. That is only half true. The code is gone, but the errors are still possible. Decryption can fail when:

  • the Keystore master key was deleted (for example, the user cleared credentials or the Keystore was reset),
  • the file was changed or cut short,
  • the ciphertext came from another file (the associatedData check).

In these cases AeadSerializer throws a CorruptionException, like our JSON serializer does for bad JSON. Without a corruption handler, the exception reaches dataStore.data and the Flow fails.

This is why the DataStore above has this line:

.setCorruptionHandler(ReplaceFileCorruptionHandler { SessionData() })

If the file cannot be read, DataStore replaces it with an empty SessionData and the user sees the login screen again. For a session token this is fine. For data the user cannot recreate, you need a better plan, for example logging the error or loading the data again from your backend.

The backup problem

We did not expect this one. New Android projects have android:allowBackup=”true” by default, and Auto Backup includes files and SharedPreferences. So both session.json and the keyset in session_keyset_prefs.xml can go into the user’s backup.

But Keystore keys are never backed up. They stay on the device that created them.

Now think about a user who restores a backup on a new phone. The encrypted session file comes back. The encrypted keyset comes back. But the master key that decrypts the keyset does not exist on the new phone.

We wanted to see what really happens, so we tested it on an emulator. We logged in, made a backup, and uninstalled the app. Uninstalling deletes the app’s Keystore keys, so it is like a new phone. Then we installed it again and let Android restore the data. The app crashed on start:

java.security.InvalidKeyException: Keystore cannot load the key with ID: master_key
...
at com.google.crypto.tink.integration.android.AndroidKeysetManager$Builder.build(AndroidKeysetManager.java:308)
at com.example.encryptiondatastore.data.di.AeadModule.provideAead(AeadModule.kt:32)
Every launch after restoring the backup on a “new” device.

Look where it fails: in AeadModule, when Tink loads the keyset. DataStore is not used yet at this point. So the corruption handler cannot help here, because it only handles reading session.json.

The fix is to exclude both files from backups. For Android 12 and higher, in data_extraction_rules.xml:

<data-extraction-rules>
<cloud-backup>
<exclude domain="file" path="datastore/session.json" />
<exclude domain="sharedpref" path="session_keyset_prefs.xml" />
</cloud-backup>
<device-transfer>
<exclude domain="file" path="datastore/session.json" />
<exclude domain="sharedpref" path="session_keyset_prefs.xml" />
</device-transfer>
</data-extraction-rules>

For Android 11 and lower, in backup_rules.xml:

<full-backup-content>
<exclude domain="file" path="datastore/session.json" />
<exclude domain="sharedpref" path="session_keyset_prefs.xml" />
</full-backup-content>

Losing a session token on a new phone is OK. A crash on every launch is not. These are the commands that AI agent used while testing, if you want to try it yourself:

adb shell bmgr enable true
adb shell bmgr transport com.android.localtransport/.LocalTransport
adb shell bmgr backupnow com.example.encryptiondatastore
adb uninstall com.example.encryptiondatastore
adb install app-debug.apk # data is restored during install

Migrating your old encrypted data

If you already have users with data stored the old way, you do not want to log them all out with the update. DataStore’s DataMigration solves this. It runs before the first read, but only when shouldMigrate returns true.

class LegacySessionMigration @Inject constructor(
private val legacyStore: DataStore<Preferences>,
private val cipher: LegacySessionCipher,
) : DataMigration<SessionData> {

override suspend fun shouldMigrate(currentData: SessionData): Boolean =
currentData.token.isEmpty() && legacyStore.data.first()[LEGACY_TOKEN] != null

override suspend fun migrate(currentData: SessionData): SessionData {
val prefs = legacyStore.data.first()
return try {
SessionData(
email = prefs[LEGACY_EMAIL]?.let(cipher::decrypt).orEmpty(),
token = cipher.decrypt(prefs[LEGACY_TOKEN].orEmpty()),
)
} catch (e: GeneralSecurityException) {
currentData
} catch (e: IllegalArgumentException) {
currentData
}
}

override suspend fun cleanUp() {
legacyStore.edit { it.clear() }
}
}

The migration reads the old data with a normal Preferences DataStore, so you need one more dependency (the same version as the others):

datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }

The old store has its own small Hilt module. The file name (legacy_session) and the Keystore alias (legacy_session_key) are only examples. Use the names from your old code, or the migration will not find any data.

@Module
@InstallIn(SingletonComponent::class)
object LegacyDataStoreModule {

@Provides
@Singleton
fun provideLegacyDataStore(
@ApplicationContext context: Context,
@ApplicationScope scope: CoroutineScope,
): DataStore<Preferences> = PreferenceDataStoreFactory.create(
scope = scope,
produceFile = { context.preferencesDataStoreFile("legacy_session") },
)

@Provides
fun provideLegacySessionCipher(): LegacySessionCipher =
LegacySessionCipher.fromAndroidKeyStore("legacy_session_key")
}

LegacySessionCipher does the opposite of the old write helper. It splits the stored string at :iv:, converts both parts back to bytes, and decrypts them with the old Keystore key. One small decision makes it easy to test: the key is passed in as a function instead of being read from the Keystore directly.

class LegacySessionCipher(private val secretKey: () -> SecretKey?) {

fun decrypt(stored: String): String {
val parts = stored.split(":iv:")
if (parts.size != 2) throw GeneralSecurityException("Unexpected legacy format")
val (iv, encryptedData) = parts.map { part -> part.split("|").map(String::toByte).toByteArray() }
val key = secretKey() ?: throw GeneralSecurityException("Legacy key not found")

val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(128, iv))
return Json.decodeFromString<String>(cipher.doFinal(encryptedData).decodeToString())
}

companion object {
fun fromAndroidKeyStore(keyAlias: String) = LegacySessionCipher {
KeyStore.getInstance("AndroidKeyStore")
.apply { load(null) }
.getKey(keyAlias, null) as? SecretKey
}
}
}

We catch the decryption errors on purpose. If the old data is broken or the old key is missing, it is better to start with an empty session than to break the new store as well. cleanUp function still runs in this case, so the app does not try the migration again on every launch.

This also handles the backup case from the previous section. The old preferences file can be restored on a new phone, but the old Keystore key cannot. The migration finds no key, returns a logged-out session and deletes the old data.

Then add the migration to the builder. Take it as a parameter of provideSessionDataStore (Hilt can inject it) and call addMigrations:

DataStore.Builder(storage = ..., context = scope.coroutineContext)
.addMigrations(listOf(legacySessionMigration))
.setCorruptionHandler(ReplaceFileCorruptionHandler { SessionData() })
.build()

We tested the migration with unit tests (see below). We did not test it on a real install that still has old data, so please check this with your own app’s data before you release it.

Moving from EncryptedSharedPreferences instead? The idea is the same: read the values in migrate and clear the old file in cleanup process.

Testing without a device

You do not need the Android Keystore to test this. Tink can create a keyset in memory, so normal JVM unit tests are enough:

@Before
fun setUp() {
AeadConfig.register()
aead = KeysetHandle.generateNew(PredefinedAeadParameters.AES256_GCM)
.getPrimitive(RegistryConfiguration.get(), Aead::class.java)
}
@Before
fun setUp() {
AeadConfig.register()
aead = KeysetHandle.generateNew(PredefinedAeadParameters.AES256_GCM)
.getPrimitive(RegistryConfiguration.get(), Aead::class.java)
}

@Test
fun `session survives a round trip and is not stored as plain text`() = runTest {
val serializer = serializerFor("session.json")

val bytes = serializer.encode(session)

assertFalse(bytes.decodeToString().contains("secret-token"))
assertEquals(session, serializer.readFrom(bytes.inputStream()))
}

@Test
fun `ciphertext from another file is rejected`() = runTest {
val bytes = serializerFor("session.json").encode(session)

val result = runCatching { serializerFor("other.json").readFrom(bytes.inputStream()) }

assertTrue(result.exceptionOrNull() is CorruptionException)
}

private fun serializerFor(fileName: String) =
AeadSerializer(aead, SessionDataSerializer, fileName.encodeToByteArray())

private suspend fun AeadSerializer<SessionData>.encode(data: SessionData): ByteArray =
ByteArrayOutputStream().also { writeTo(data, it) }.toByteArray()

The second test is the most useful one. It shows that the associatedData protection from Step 4 really works. A third test, not shown here, checks the same thing for ciphertext written with a different key.

You can test the migration the same way. LegacySessionCipher takes its key as a function, so the test gives it a normal AES key from KeyGenerator. Then it writes some values in the old format and checks three things: the new store reads them, the old store is empty after the migration, and broken legacy data becomes a logged-out session. For runTest you need one more test dependency:

kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version = "1.10.2" }

The other dependencies are already in the project.

Things to know before you use it

  • It is alpha. The API can change between releases. Pin the version and read the release notes before you update.
  • Key rotation is not automatic. A Tink keyset can hold more than one key. It encrypts with the primary key and can still decrypt with the older ones. But you decide when to rotate, and you have to start it yourself.
  • It is made for typed DataStore. AeadSerializer wraps a Serializer<T>, so it works well with a data class like SessionData. If you keep many separate keys in Preferences DataStore, it is usually cleaner to move the sensitive ones into one small data class.

Was it worth it?

For us, yes. A short comparison:

  • EncryptedSharedPreferences: very easy, but deprecated, synchronous, and no Flow support.
  • Manual Keystore + DataStore: it works, but you maintain all the crypto code, the storage format, and their bugs.
  • datastore-tink: little setup, Tink handles the crypto, and errors come as normal DataStore corruption. But it is still alpha, and you still have to think about backups.

The best part for us is the code we could delete. The SecurityUtil class, the separator format and the secure read/write helpers are all gone. Now we have a normal serializer, a few lines of setup, and two XML rules that we should have added from the start.

The full sample is here: github.com/burkido/official-datastore-encryption-sample

References


Finally The Official Way of Secure DataStore is Here! was originally published in ProAndroidDev on Medium, where people are continuing the conversation by highlighting and responding to this story.