Android – DataStore and the Keystore

September 19, 20268 min readUpdated 10/11/2026

An app remembers things for different reasons, and each reason has a different right answer for where to put it. Getting this wrong is rarely a crash. It is a session token in a plain-text file, a cart that vanishes on relaunch, or a restored phone signed in as somebody else.

This lesson is the decision first, then the two tools this app uses: DataStore for ordinary values, and the Android Keystore for the one secret.

Where each thing belongs

Here is everything this app keeps, and where:

  • The session token — encrypted on disk with a Keystore key. It is a credential: anyone holding it is the customer until it expires.
  • Which cart is this device's — plain DataStore. A cart id identifies a basket, not a person, and the server treats it as a claim rather than an authorisation.
  • The cart's contents — on the server. The device keeps only the id, which is what makes the cart survive a relaunch, a force-stop, or the process being killed in the background.
  • The menu — in memory, refetched each launch. Prices change; a stale cached menu is a support ticket.
  • Half-typed forms — saved instance state (lesson 5), which survives process death and is discarded when the task ends.
  • Card numbers — nowhere. Never. Stripe holds them; the app holds an opaque id and the last four digits for display.

One option is absent: a database. Room, Android's SQLite library, is the right tool for structured data you query or need offline — a message history, a downloaded catalogue. This app has none, because its source of truth is the server. Two small key-value stores are all it needs.

Keys, named once

enum class StorageKey(val raw: String) {
    /** The JWT. Only ever written through `SecureStore`, never in plain text. */
    AUTH_TOKEN("pizza.token"),

    /** Which server-side cart belongs to this device. Not a secret — see `CartIdentifierStore`. */
    CART_ID("pizza.cartId"),
}

A typo in a storage key fails silently — the read returns null and the app behaves as if the customer had never signed in. An enum removes that class of bug, and doubles as a list of everything the app leaves on the device.

DataStore

For years the answer for small values was SharedPreferences, and it has three problems that DataStore exists to fix. Its first read blocks the calling thread while an XML file is parsed — often the main thread, at startup, which is a classic source of ANRs. Its apply() writes in the background and swallows any failure. And it has no transactions, so two writers can interleave.

DataStore's API is coroutines and Flow, so it cannot be read on the main thread by accident:

interface KeyValueStore {
    suspend fun string(key: StorageKey): String?
    suspend fun set(key: StorageKey, value: String)
    suspend fun remove(key: StorageKey)
}
class DataStoreKeyValueStore(
    private val dataStore: DataStore<Preferences>,
) : KeyValueStore {
    override suspend fun string(key: StorageKey): String? =
        dataStore.data.first()[stringPreferencesKey(key.raw)]

    override suspend fun set(key: StorageKey, value: String) {
        dataStore.edit { it[stringPreferencesKey(key.raw)] = value }
    }

    override suspend fun remove(key: StorageKey) {
        dataStore.edit { it.remove(stringPreferencesKey(key.raw)) }
    }
}

data is a Flow of the whole preferences file: it emits the current contents and then every change. first() takes the current value and stops — a one-shot read. A screen that wanted to react to a setting changing would collect the flow instead. edit { } is a transaction: the block runs against a mutable copy, and the change is written atomically or not at all.

The app depends on its own KeyValueStore interface rather than on DataStore directly, so tests use an in-memory map and never touch a file.

The cart id is the store's simplest user, and a useful contrast with the token:

class CartIdentifierStore(private val store: KeyValueStore) {
    suspend fun identifier(): UuidString? = store.string(StorageKey.CART_ID)

    suspend fun save(identifier: UuidString) = store.set(StorageKey.CART_ID, identifier)

    suspend fun clear() = store.remove(StorageKey.CART_ID)
}

Plain storage, because the value is not a secret. Putting it in the Keystore-backed store would cost a cryptographic round trip on every launch to protect something worthless to an attacker. Encrypt what needs it, and only that — and when in doubt about a new value, ask what an attacker could do with it.

Exactly one instance

DataStore throws if two instances are ever active for the same file. The documented way to guarantee one is a property delegate at the top level of a file:

private val Context.pizzaDataStore: DataStore<Preferences> by preferencesDataStore(name = "pizza")

The delegate creates the instance on first access and returns the same one ever after, for the life of the process. Hilt then wraps it once, as a singleton, and every store shares it.

The Keystore

The token needs more than a file. The Android Keystore is a system service that generates and holds cryptographic keys outside your process. On most phones made since 2018 it is backed by hardware — a trusted execution environment, sometimes a separate security chip — so the key material is never in your app's memory at all. You hand the Keystore plaintext and get ciphertext back.

The app uses it to encrypt the token with AES-256 in GCM mode, and stores the ciphertext in the ordinary DataStore:

private fun secretKey(): SecretKey {
    val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
    (keyStore.getEntry(keyAlias, null) as? KeyStore.SecretKeyEntry)?.let { return it.secretKey }

    val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE)
    generator.init(
        KeyGenParameterSpec.Builder(
            keyAlias,
            KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
        )
            .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
            .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
            .setKeySize(256)
            .build(),
    )
    return generator.generateKey()
}

The first call generates the key inside the Keystore under an alias; every later call finds it by that alias. PURPOSE_ENCRYPT or PURPOSE_DECRYPT restricts what the key may ever be used for. There is no export: the key cannot be read out, only used.

private fun encrypt(plaintext: String): String {
    val cipher = Cipher.getInstance(TRANSFORMATION)
    cipher.init(Cipher.ENCRYPT_MODE, secretKey())
    val ciphertext = cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8))
    return Base64.encodeToString(cipher.iv + ciphertext, Base64.NO_WRAP)
}
private fun decrypt(stored: String): String {
    val bytes = Base64.decode(stored, Base64.NO_WRAP)
    val iv = bytes.copyOfRange(0, IV_LENGTH)
    val ciphertext = bytes.copyOfRange(IV_LENGTH, bytes.size)

    val cipher = Cipher.getInstance(TRANSFORMATION)
    cipher.init(Cipher.DECRYPT_MODE, secretKey(), GCMParameterSpec(TAG_LENGTH_BITS, iv))
    return String(cipher.doFinal(ciphertext), Charsets.UTF_8)
}

GCM needs a fresh 12-byte initialisation vector for every encryption, and the Keystore insists on generating it itself — by default it rejects a caller-supplied IV for encryption, precisely so nobody reuses one by accident. The IV is not secret, so it is stored in front of the ciphertext and split off again to decrypt. GCM is also authenticated: tamper with a single byte of the stored value and doFinal throws rather than returning garbage.

Keys that require the user

A Keystore key can be made to work only after the user authenticates — setUserAuthenticationRequired(true), with a fingerprint or the screen lock. Banking apps use this so a stolen, unlocked phone still cannot use the stored credential without a fresh biometric check. The trade-off is real: such a key is invalidated when the user adds a new fingerprint, and every read needs a prompt. A pizza app reading its token at launch should not ask for a fingerprint, so this key is not bound to authentication. Choose per secret.

Why not EncryptedSharedPreferences

For several years the standard answer was EncryptedSharedPreferences from androidx.security:security-crypto. That library is now deprecated. It was a thin layer over exactly what is above — a Keystore key and AES-GCM — plus SharedPreferences' own problems. Twenty lines written against the platform API leave nothing deprecated to migrate away from later, and they are worth understanding rather than hiding.

The token store

On top of the encrypted store sits TokenStore, which the coroutines lesson showed guarding the token with a Mutex and caching it in memory. The detail worth repeating here is how it treats a storage failure:

override suspend fun currentToken(): String? = mutex.withLock {
    if (!hasLoaded) {
        cachedToken = try {
            secureStore.string(StorageKey.AUTH_TOKEN)
        } catch (e: CancellationException) {
            throw e
        } catch (e: Exception) {
            Log.w(TAG, "Stored token could not be read; treating it as signed out.", e)
            null
        }
        hasLoaded = true
    }
    cachedToken
}

If the token cannot be read — the key was invalidated, the data is corrupt — the only honest interpretation is "there is no session". The customer signs in again; nothing crashes. A write failure is treated the same way in reverse: the in-memory token still works for this session, and a failed sign-in over a storage error would be worse than a session that does not survive a relaunch.

None of this needs a device to test. Both stores have in-memory doubles, and the token store's tests use a small fake that counts reads and can be told to fail — which is how "reads storage once and caches", "a storage failure reads as no session" and "a failed write still leaves a usable session" are each pinned down on the JVM, in milliseconds.

Backups: keeping secrets on one phone

Android backs up app data to the user's Google account and copies it to a new phone during setup. For most data that is a kindness. For this app it is pointless at best: the token was encrypted with a key that never leaves the old device, so on the new phone it is ciphertext nobody can decrypt. The app would fail to read it, treat that as signed out, and the customer would sign in anyway. The cart id would point a new phone at an old basket.

So the app opts out, explicitly, for both mechanisms:

<data-extraction-rules>
    <cloud-backup>
        <exclude domain="root" />
        <exclude domain="file" />
        <exclude domain="database" />
        <exclude domain="sharedpref" />
    </cloud-backup>
    <device-transfer>
        <exclude domain="root" />
        <exclude domain="file" />
        <exclude domain="database" />
        <exclude domain="sharedpref" />
    </device-transfer>
</data-extraction-rules>
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="false"

Three attributes for one decision, because the platform changed twice. allowBackup covers older versions; Android 12 introduced dataExtractionRules, which separates cloud backup from device-to-device transfer — and which allowBackup="false" alone no longer fully controls; fullBackupContent keeps Android Lint satisfied that versions 8 through 11 are covered. Lint flags a manifest missing any of them, which is how this app found the gap.

Compared with iOS

The iOS app stores its token in the Keychain with kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly — "this device only" being the same decision as excluding it from backup here. The difference is that the Keychain is itself the encrypted store; on Android the Keystore holds only the key, and the app encrypts and stores the data itself. Same guarantee, one more step.

Next

The token is read once, at launch, before the app knows who is signed in. That moment — and the moment the app leaves the screen — are the subject of the next lesson: the app lifecycle, the splash screen, and saving work before Android kills the process.