Storage 10 min read

DataStore 1.3.0: The Encryption API That Was Missing, Plus KMP and a Better Builder

DataStore replaced SharedPreferences. That was the pitch in 2020 — coroutine-native, type-safe, crash-safe with atomic writes. And largely it delivered. But there was one glaring omission that anyone who'd ever encrypted SharedPreferences noticed immediately: DataStore had no built-in encryption story. You either brought your own serializer wrapping EncryptedSharedPreferences-style logic, or you accepted that your data sat on disk in plaintext and relied on Android's disk encryption at the OS level.

The 1.3.0 alpha series, which has been steadily building since late 2025, finally addresses that and adds several other things that have been on the wishlist for a while. The three that matter most: a real encryption API backed by Tink, a new DataStore.Builder that enforces better structured concurrency than the existing factory approach, and Kotlin Multiplatform Web support with proper file system semantics. Let me go through each of these in the order I think they matter for production apps.

New
AES-256-GCM encryption via datastore-tink artifact
New
DataStore.Builder requiring CoroutineContext, not CoroutineScope
KMP
Web via OPFS, localStorage, sessionStorage

The Encryption API That Should Have Been There from Day One

Before getting into the API, it's worth being precise about what "no built-in encryption" actually meant in practice. Android's full-disk encryption and file-based encryption protect your DataStore files at rest against physical device access without the lock screen credential. What they don't protect against is other apps or processes with elevated access reading your store, or situations where your app's data directory is accessible (rooted devices, backup restores into different contexts, targeted malware). For most apps storing user preferences or UI state, the OS-level protection is fine. For apps handling auth tokens, user health data, financial preferences, or anything governed by HIPAA/GDPR with an explicit "store securely" obligation, plaintext DataStore was a compromise you had to patch yourself.

DataStore 1.3.0 adds a datastore-tink artifact that provides an AeadSerializer — a wrapper that applies AES-256-GCM encryption and decryption around any existing DataStore serializer. The key management goes through Android Keystore, which means the encryption key never leaves the secure hardware element and is scoped to your app. Here's the full setup:

// 1. Build the encryption key via Android Keystore
val keysetHandle = AndroidKeysetManager.Builder()
    .withSharedPref(
        applicationContext,
        "my_keyset",
        "my_keyset_prefs"
    )
    .withKeyTemplate(
        KeyTemplate.createFrom(PredefinedAeadParameters.AES256_GCM)
    )
    .withMasterKeyUri("android-keystore://my_master_key")
    .build()
    .keysetHandle

// 2. Wrap your existing serializer
val encryptedSerializer = AeadSerializer(
    aead = keysetHandle.getPrimitive(
        RegistryConfiguration.get(),
        Aead::class.java
    ),
    wrappedSerializer = UserPreferencesSerializer,
    associatedData = "user_preferences.pb".encodeToByteArray()
)

// 3. Create DataStore exactly as before — the serializer handles the rest
val dataStore = dataStore(
    fileName = "user_preferences.pb",
    serializer = encryptedSerializer
)

A few things about this design are worth appreciating. The AeadSerializer is a wrapper, not a replacement for your serializer — you keep your existing Proto or custom serializer and compose encryption on top of it. This makes it straightforward to add encryption to an existing DataStore without changing your data model. The associatedData parameter binds the encrypted content to the file name, preventing an attacker from swapping ciphertext between two different DataStore files to confuse your app. And because the key lives in Android Keystore, it's hardware-backed on modern devices, rotation is handled by Tink's key versioning system, and the key becomes inaccessible after device reboot until the user unlocks.

Migration caution: Adding AeadSerializer to an existing unencrypted DataStore will cause a CorruptionException on first read — the plaintext bytes aren't valid ciphertext. You'll need a one-time migration that reads the old unencrypted store, writes to a new encrypted store, and swaps the file. Plan this before enabling encryption on an existing app.

The dependency is a separate artifact, which I think is the right call. Tink is a substantial dependency for apps that genuinely don't need encryption, and keeping it optional avoids a forced APK size hit for the majority of DataStore users. Add it only where your threat model requires it:

// build.gradle.kts
dependencies {
    implementation("androidx.datastore:datastore:1.3.0-alpha09")
    implementation("androidx.datastore:datastore-tink:1.3.0-alpha09")
    // Tink Android runtime
    implementation("com.google.crypto.tink:tink-android:1.15.0")
}

DataStore.Builder: Why CoroutineContext Beats CoroutineScope

The existing DataStoreFactory.create() and the dataStore() delegate both take a CoroutineScope. That sounds reasonable until you think about what CoroutineScope actually is: it's both a lifecycle container and a CoroutineContext. When you pass a scope that belongs to a short-lived component — say, a viewModelScope or a scope tied to a particular screen — and that scope gets cancelled, your DataStore stops processing reads and writes. Worse, it can fail silently, leaving callers waiting for a collect that will never complete.

The new DataStore.Builder API, added in alpha07, takes a CoroutineContext instead. This is a subtle but meaningful distinction: you provide the scheduling context (dispatcher, job), but the builder manages the lifetime internally. You can no longer accidentally pass a scope that gets cancelled when a ViewModel clears:

// Old approach — vulnerable to scope cancellation
val dataStore = DataStoreFactory.create(
    serializer = UserPreferencesSerializer,
    scope = viewModelScope  // BAD: cancelled when ViewModel clears
) { context.dataStoreFile("preferences.pb") }

// New builder — takes CoroutineContext, not CoroutineScope
val dataStore = DataStore.Builder(UserPreferencesSerializer) {
    context.dataStoreFile("preferences.pb")
}
    .setCoroutineContext(Dispatchers.IO)
    .setCorruptionHandler(
        ReplaceFileCorruptionHandler { UserPreferences.getDefaultInstance() }
    )
    .build()

The practical implication: for production apps, you should be creating your DataStore instance at application scope anyway — either in an Application subclass, a Hilt @Singleton module, or a process-scoped coroutine context. If you were doing that correctly, the builder doesn't change much in practice. If you were creating DataStore in a ViewModel or Activity scope, the builder will force you toward the right pattern.

In Nodat, I've always kept DataStore in a singleton module because the user preferences store is read on startup and written from multiple screens — sharing one instance avoids both resource overhead and conflicting write batches. The new builder formalizes what was already the right approach and makes the wrong approach harder to stumble into.

Structured concurrency note: The CoroutineContext you pass must include an application-scoped Job (or be a supervisor context). If the Job is cancelled, DataStore will stop processing operations. When using Hilt, binding this in a @Provides @Singleton method with @ApplicationContext and a globally-scoped context is the cleanest approach.

DataStore Goes Multiplatform: Web Storage Backends

This one is primarily relevant if you're building with Kotlin Multiplatform and sharing a data layer across Android and a web target. DataStore now has proper multiplatform support for browser environments with three distinct storage backends, each targeting a different use case.

sessionStorage (alpha01) is the simplest: temporary per-tab data that disappears when the tab closes. Useful for transient UI state in KMP web apps — active filters, scroll position, draft form content.

localStorage (alpha05) persists across sessions and includes cross-tab synchronization via the BroadcastChannel API. When one tab writes, other open tabs in the same origin get notified and can update their in-memory state. This is the browser equivalent of what DataStore already does with Flow collection — multiple observers see the same updates.

WebOpfsStorage (alpha08) is the most capable. OPFS — the Origin Private File System — gives a web app access to a private file system that isn't directly accessible from the browser's developer tools or by other origins. Combined with the Web Locks API for exclusive write access and the BroadcastChannel for cross-tab notifications, this is the closest to how DataStore works on Android: file-based, atomic, exclusive writes. The API mirrors how you'd use file storage on any other platform:

// shared commonMain DataStore setup
expect fun createDataStore(): DataStore<AppPreferences>

// androidMain
actual fun createDataStore(): DataStore<AppPreferences> =
    DataStore.Builder(AppPreferencesSerializer) {
        File(filesDir, "app_prefs.pb")
    }
        .setCoroutineContext(Dispatchers.IO)
        .build()

// wasmJsMain / jsMain — uses OPFS for file semantics
actual fun createDataStore(): DataStore<AppPreferences> =
    DataStore.Builder(AppPreferencesSerializer) {
        WebOpfsStorage("app_prefs.pb")
    }
        .build()

If your KMP project currently uses a per-platform preferences wrapper (like multiplatform-settings or hand-rolled expect/actual SharedPreferences on Android + localStorage on web), DataStore 1.3.0 is the first release where you could realistically use the same DataStore<T> interface across all targets without a seam.

The explicit storage type APIs

Alpha08 also introduced WebLocalStorage and WebSessionStorage as explicit types, replacing the implicit inference that earlier alphas used. This is a breaking API change in alpha, so if you're on a pre-alpha08 build and using web targets, check the migration notes before bumping.

Smaller Additions Worth Knowing

ReThrowCorruptionHandler is now public (alpha09). Previously internal, this handler lets you surface corruption exceptions to calling code instead of silently replacing the corrupted file with a default instance. It's the right choice when you need to show the user an error or trigger a diagnostic flow instead of silently discarding corrupted preferences. The default ReplaceFileCorruptionHandler is still the right choice for most apps — silent recovery from corruption is usually preferable to a crash — but for apps with complex stored state where data loss needs to be surfaced explicitly, the public ReThrowCorruptionHandler gives you that control.

System tracing support was added in alpha06 via DataStoreFactory.createWithTracing(). This integrates DataStore's internal read/write operations with androidx.tracing, meaning they show up in Perfetto traces and Android Studio's CPU profiler as named slices. If you've ever looked at a Perfetto trace and wondered why there's a periodic IO spike on the main thread (it's probably DataStore flushing — you shouldn't be on the main thread, but it happens in practice), this makes it immediately visible and attributable.

Baseline Profiles were added in alpha05. DataStore's critical paths — the cold read of the preferences file on first collection, the serialization/deserialization hot path, the write-and-flush path — are now included in the library's own baseline profile. This means if you're using Baseline Profiles in your app, DataStore code is already pre-compiled at install time. For apps like Nodat where preferences are read during the startup sequence to configure the UI before the first frame, this translates to measurably faster initial collection — the same class of improvement I described in the startup time post.

Should You Upgrade Now?

Alpha09 is still in alpha — the API surface is mostly stable at this point but remains subject to change before the final 1.3.0 release. Split your decision by feature:

The dependency line for the latest alpha, without encryption:

implementation("androidx.datastore:datastore:1.3.0-alpha09")
// or for Preferences DataStore
implementation("androidx.datastore:datastore-preferences:1.3.0-alpha09")

DataStore was the right replacement for SharedPreferences. 1.3.0 is closing the gap on the remaining reasons people still reach for SharedPreferences — primarily the lack of encryption and the want for a simpler, safer initialization API. Encryption in particular is a feature that should have been in 1.0. It's here now, and it's implemented properly. That's what matters.

Comments 0

No comments yet. Be the first to leave one!

Leave a comment