A Locked Drawer Beats a Closed One: Encrypting Auth Tokens Before They Reach DataStore
Keystore-backed AES-GCM or Tink, wired into DataStore, with the production failure modes nobody puts in the sample app. 🧠 TL;DR (Too Long; Didn’t Read) Jetpack DataStore gives you atomic, consistent persiste
Keystore-backed AES-GCM or Tink, wired into DataStore, with the production failure modes nobody puts in the sample app.
🧠 TL;DR (Too Long; Didn’t Read)
- Jetpack DataStore gives you atomic, consistent persistence. It does not give you confidentiality.
- Encrypt tokens with a non-exportable key in the Android Keystore (or let Tink manage a keyset wrapped by a Keystore master key) before the bytes touch disk.
- The cipher is the easy 20%. The other 80% is what happens on backup restore, key invalidation, IV handling, threading, and error recovery.
- Treat any decryption failure as “signed out”, never as “retry forever”.
1. The problem: DataStore is a file, and files get read
Teams often migrate from SharedPreferences to DataStore and quietly assume the migration fixed security. It did not. Both are plain files under your app's private directory. App sandboxing keeps other apps away, but that guarantee has holes:
- A rooted device exposes every app’s private files.
- Auto Backup and device-to-device transfer can copy the file off the device.
- Any sandbox-escape bug turns “private” into “readable”.
A refresh token is effectively a long-lived password. If it leaks, the attacker does not need your user’s credentials, and rotating the user’s password may not even revoke it. That is why the token deserves a second layer: encrypt it, and keep the key somewhere your process cannot simply read it out.
What we are (and are not) defending against
Say this out loud in your design doc. Encryption at rest shrinks the attack surface; it is not a force field.
2. The design in one picture
The Android Keystore holds an AES key that is generated inside secure hardware where available (TEE, or StrongBox on supported devices) and is marked non-exportable. Your process never sees raw key material. It only gets a handle that lets it ask the Keystore to encrypt or decrypt.
We store IV || ciphertext (the GCM tag is appended to the ciphertext by the JCA provider). The IV is not secret; it only has to be unique per encryption under the same key.
3. Option A: Raw Keystore with Cipher
No dependency, tiny surface, minSdk 23+. Here is a small, testable abstraction first.
interface TokenCipher {
fun encrypt(plain: ByteArray): ByteArray
fun decrypt(blob: ByteArray): ByteArray
}
And the Keystore-backed implementation:
import android.os.Build
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.security.keystore.StrongBoxUnavailableException
import androidx.annotation.RequiresApi
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
class KeystoreTokenCipher(
private val alias: String = "auth_token_key_v1",
) : TokenCipher {
private val keyStore: KeyStore =
KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
override fun encrypt(plain: ByteArray): ByteArray {
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.ENCRYPT_MODE, getOrCreateKey())
// The provider generates a fresh random 12-byte IV for us.
val iv = cipher.iv
check(iv.size == IV_SIZE) { "Unexpected IV size: ${iv.size}" }
return iv + cipher.doFinal(plain)
}
override fun decrypt(blob: ByteArray): ByteArray {
require(blob.size > IV_SIZE) { "Blob too short" }
val iv = blob.copyOfRange(0, IV_SIZE)
val body = blob.copyOfRange(IV_SIZE, blob.size)
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(
Cipher.DECRYPT_MODE,
getKey() ?: error("Key missing for alias $alias"),
GCMParameterSpec(TAG_BITS, iv),
)
return cipher.doFinal(body)
}
private fun getKey(): SecretKey? =
(keyStore.getEntry(alias, null) as? KeyStore.SecretKeyEntry)?.secretKey
// The alias is process-wide, so the lock must be too (not per instance).
private fun getOrCreateKey(): SecretKey =
getKey() ?: synchronized(KEY_LOCK) { getKey() ?: createKey() }
private fun createKey(): SecretKey =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
createKeyPreferringStrongBox()
} else {
generate(strongBox = false) // StrongBox does not exist below API 28
}
@RequiresApi(Build.VERSION_CODES.P)
private fun createKeyPreferringStrongBox(): SecretKey =
try {
generate(strongBox = true)
} catch (_: StrongBoxUnavailableException) {
generate(strongBox = false)
}
// A fresh KeyGenerator per attempt: never re-init an instance that already failed.
private fun generate(strongBox: Boolean): SecretKey =
KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE)
.apply { init(keySpec(strongBox)) }
.generateKey() // StrongBoxUnavailableException can surface here (or from init)
private fun keySpec(strongBox: Boolean): KeyGenParameterSpec =
KeyGenParameterSpec.Builder(
alias,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(256)
.setRandomizedEncryptionRequired(true) // refuses caller-supplied IVs
.apply {
if (strongBox && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
setIsStrongBoxBacked(true)
}
}
.build()
private companion object {
const val ANDROID_KEYSTORE = "AndroidKeyStore"
const val TRANSFORMATION = "AES/GCM/NoPadding"
const val IV_SIZE = 12
const val TAG_BITS = 128
val KEY_LOCK = Any()
}
}
A few decisions worth defending in code review:
-
setRandomizedEncryptionRequired(true)is the default, and you should leave it. It makes the Keystore reject an IV you supply, which closes off the most common GCM footgun (see failure mode 3). -
StrongBox is opportunistic and API 28+ only.
setIsStrongBoxBackedandStrongBoxUnavailableExceptionwere added in Android 9, so on API 23 to 27 touching them throwsNoSuchMethodErrororNoClassDefFoundError. The code gates the StrongBox attempt behindSDK_INT >= Pand keeps it in its own@RequiresApifunction. On hardware that lacks StrongBox the exception can surface frominitor fromgenerateKey(), so both calls sit inside thetry. The fallback builds a newKeyGeneratorrather than re-initializing the failed one, because some vendor implementations misbehave when a generator is reused after a failed StrongBox attempt. Falling back to the TEE keeps the app working. -
Versioned alias (
_v1) gives you a clean path to rotate the key later without guessing what an old blob was encrypted with. -
Key creation is guarded by a process-wide lock (a companion-object monitor with a double check), not
@Synchronized. The alias lives in the process-wide Keystore, so a per-instance lock would not stop twoKeystoreTokenCipherinstances from racing. Still, create one instance and share it. Multiple processes in one app are not covered by any in-process lock, so keep token access in the main process.
4. Option B: Tink
If you want audited primitives, keyset management and a path to key rotation, Tink’s Android flavor wraps a keyset with a Keystore master key for you.
// build.gradle.kts
implementation("com.google.crypto.tink:tink-android:1.23.0") // pin the version you have verified
import android.content.Context
import com.google.crypto.tink.Aead
import com.google.crypto.tink.KeyTemplates
import com.google.crypto.tink.RegistryConfiguration
import com.google.crypto.tink.aead.AeadConfig
import com.google.crypto.tink.integration.android.AndroidKeysetManager
class TinkTokenCipher(context: Context) : TokenCipher {
private val aead: Aead
init {
AeadConfig.register()
val keysetHandle = AndroidKeysetManager.Builder()
.withSharedPref(context.applicationContext, KEYSET_NAME, PREF_FILE)
.withKeyTemplate(KeyTemplates.get("AES256_GCM"))
.withMasterKeyUri(MASTER_KEY_URI)
.build()
.keysetHandle
aead = keysetHandle.getPrimitive(RegistryConfiguration.get(), Aead::class.java)
}
override fun encrypt(plain: ByteArray): ByteArray = aead.encrypt(plain, ASSOCIATED_DATA)
override fun decrypt(blob: ByteArray): ByteArray = aead.decrypt(blob, ASSOCIATED_DATA)
private companion object {
const val KEYSET_NAME = "token_keyset"
const val PREF_FILE = "token_keyset_prefs"
const val MASTER_KEY_URI = "android-keystore://token_master_key"
val ASSOCIATED_DATA = "auth-tokens:v1".toByteArray()
}
}
Three things to notice. First, AndroidKeysetManager is the long-standing, widely deployed way to keep a Tink keyset wrapped by a Keystore key. Newer Tink releases (1.16 onward) also ship an AndroidKeystore API that the Tink release notes describe as the preferred way to work with the Keystore directly, so check the current Tink docs before starting a greenfield project. Second, the associated data is authenticated but not encrypted, so a blob encrypted for one purpose cannot be silently swapped into another slot. Third, getPrimitive(RegistryConfiguration.get(), Aead::class.java) is the form used in Tink's own Java documentation; older code you find online uses the deprecated single-argument getPrimitive(Aead::class.java). Pin one Tink version and compile against it rather than copying snippets across versions.
Rule of thumb: if the feature is “one secret, one purpose, no rotation”, raw Keystore is enough. The moment someone says “key rotation” or “we will have several encrypted stores”, reach for Tink instead of growing your own keyset logic.
5. Wiring it into DataStore
You have two reasonable shapes. Encrypting individual preference values works, but a custom Serializer encrypts the whole file atomically and keeps the model typed. I prefer the serializer.
import androidx.datastore.core.CorruptionException
import androidx.datastore.core.Serializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import java.io.InputStream
import java.io.OutputStream
import java.security.GeneralSecurityException
@Serializable
data class AuthSession(
val accessToken: String = "",
val refreshToken: String = "",
val expiresAtEpochSeconds: Long = 0L,
) {
val isSignedIn: Boolean get() = refreshToken.isNotEmpty()
}
class EncryptedAuthSerializer(
private val cipher: TokenCipher,
private val json: Json = Json { ignoreUnknownKeys = true },
) : Serializer<AuthSession> {
override val defaultValue: AuthSession = AuthSession()
override suspend fun readFrom(input: InputStream): AuthSession {
val bytes = input.readBytes()
if (bytes.isEmpty()) return defaultValue
return try {
json.decodeFromString<AuthSession>(cipher.decrypt(bytes).decodeToString())
} catch (e: GeneralSecurityException) {
throw CorruptionException("Cannot decrypt session", e)
} catch (e: IllegalArgumentException) { // short blob, missing key
throw CorruptionException("Malformed session blob", e)
} catch (e: IllegalStateException) {
throw CorruptionException("Key unavailable", e)
} catch (e: SerializationException) {
throw CorruptionException("Cannot parse session", e)
}
}
override suspend fun writeTo(t: AuthSession, output: OutputStream) {
val plain = json.encodeToString(AuthSession.serializer(), t).encodeToByteArray()
output.write(cipher.encrypt(plain))
}
}
Now the store itself, with the corruption handler doing the important work: a file we cannot decrypt is replaced with the empty session.
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
import androidx.datastore.dataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
private val Context.authDataStore: DataStore<AuthSession> by dataStore(
fileName = "auth_session.enc",
serializer = EncryptedAuthSerializer(KeystoreTokenCipher()),
corruptionHandler = ReplaceFileCorruptionHandler { AuthSession() },
)
class SessionRepository(private val context: Context) {
val session: Flow<AuthSession> = context.authDataStore.data
val isSignedIn: Flow<Boolean> = session.map { it.isSignedIn }
suspend fun save(accessToken: String, refreshToken: String, expiresAt: Long) {
context.authDataStore.updateData {
AuthSession(accessToken, refreshToken, expiresAt)
}
}
suspend fun clear() {
context.authDataStore.updateData { AuthSession() }
}
}
Keep the Serializer free of Android types beyond the TokenCipher interface. That single seam lets you unit test the serializer with a trivial in-memory fake, which matters because the real Keystore is not available on the JVM.
class FakeTokenCipher : TokenCipher {
override fun encrypt(plain: ByteArray) = plain.reversedArray()
override fun decrypt(blob: ByteArray) = blob.reversedArray()
}
6. Real-world failure modes
This is where tokens and tickets collide. Each of these ships quietly and shows up as a support ticket reading “I keep getting logged out”.
6.1 Restored backup: ciphertext survives, the key does not
Auto Backup copies your files, but Keystore keys are bound to the device and are never included. After a restore onto a new phone, auth_session.enc is present and the key is not. What you see depends on your code path:
-
Key alias missing entirely (the typical restore case):
getEntryreturnsnull, and ourdecryptraisesIllegalStateException("Key missing for alias ...")before the cipher ever runs. That is why the serializer mapsIllegalStateExceptiontoCorruptionException. -
A key exists but is not the one that encrypted the blob (for example the alias was regenerated after a failed install):
doFinalfails authentication withAEADBadTagException. -
Tink: the keyset lives in SharedPreferences, which backup can restore, but the Keystore master key that wraps it does not come along, so loading the keyset throws a
GeneralSecurityExceptionwhen the keysetHandle is built, before any decrypt call. Exclude the keyset preferences file from backup too.
Two fixes, ideally both. First, exclude the file from backup. domain="file" resolves to context.filesDir, and DataStore writes to filesDir/datastore/<fileName>, so the relative path below is the right one:
<!-- res/xml/data_extraction_rules.xml (Android 12+) -->
<data-extraction-rules>
<cloud-backup>
<exclude domain="file" path="datastore/auth_session.enc" />
<exclude domain="sharedpref" path="token_keyset_prefs.xml" /> <!-- only if you use Tink -->
</cloud-backup>
<device-transfer>
<exclude domain="file" path="datastore/auth_session.enc" />
<exclude domain="sharedpref" path="token_keyset_prefs.xml" />
</device-transfer>
</data-extraction-rules>
Android 11 and below ignore that file, so mirror it for the legacy full-backup rules:
<!-- res/xml/backup_rules.xml (API 23 to 30) -->
<full-backup-content>
<exclude domain="file" path="datastore/auth_session.enc" />
<exclude domain="sharedpref" path="token_keyset_prefs.xml" />
</full-backup-content>
<!-- AndroidManifest.xml -->
<application
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
... />
Do not trust the XML on faith: a typo silently leaves the file in the backup. Run a backup and restore cycle with adb shell bmgr on a test device and confirm the restored app lands on the login screen.
Second, keep the ReplaceFileCorruptionHandler from the previous section as the safety net, because exclusion rules are a policy and a corruption handler is a guarantee.
6.2 Key invalidation when the lock screen changes
If you later tighten the key with setUserAuthenticationRequired(true) or tie it to biometric enrollment, a lock-screen or biometric change can permanently invalidate the key (KeyPermanentlyInvalidatedException on init). For background auth tokens, avoid user-auth-bound keys. Reserve those for step-up actions like "reveal saved card". If you do use them, catch the exception, delete the alias, and fall back to re-authentication.
6.3 IV reuse: the silent one
AES-GCM with a repeated IV under the same key is catastrophic: it leaks the XOR of plaintexts and can expose the authentication key. This usually appears when someone “simplifies” by hard-coding an IV, deriving it from a counter that resets on reinstall, or reusing one GCMParameterSpec across calls. The code above avoids this by letting the provider generate the IV and by leaving randomized encryption required. If your encrypt ever accepts an IV parameter, that is a review blocker.
6.4 Keystore on the main thread
Keystore operations are IPC calls into a system service, and the first call after process start can be slow on low-end hardware. DataStore already does its reads and writes on its own IO scope, so the serializer is safe as long as you do not touch KeystoreTokenCipher directly from the UI thread. A classic regression is someone adding a synchronous cipher.decrypt call inside a ViewModel init or an Interceptor that runs before the dispatcher switch. If you measure first-request latency, warm the key off the main thread. Give the work to an application-owned scope instead of creating a throwaway one inline, so it is named, injectable and cancellable in tests:
class App : Application() {
// Process-lifetime scope owned by the Application, not an anonymous one-off.
val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
override fun onCreate() {
super.onCreate()
applicationScope.launch(Dispatchers.IO) {
runCatching { KeystoreTokenCipher().encrypt(ByteArray(1)) }
}
}
}
With dependency injection, provide that scope as a singleton (for example with a Hilt @Singleton CoroutineScope) rather than reaching into App. Treat the warm-up as optional: it adds Keystore binder traffic during cold start, so keep it only if a startup trace shows it pays for itself.
6.5 Swallowed exceptions and the infinite refresh loop
The worst version of this feature is a try/catch (e: Exception) { return "" } around decryption. Now your network layer sees an empty token, gets a 401, tries to refresh using an empty refresh token, gets a 401, and loops. Fail closed instead: a decryption failure means "there is no session".
In code, that is a single boundary where crypto errors become a domain state:
sealed interface AuthState {
data object SignedOut : AuthState
data class SignedIn(val session: AuthSession) : AuthState
}
val authState: Flow<AuthState> = context.authDataStore.data
.map<AuthSession, AuthState> { s ->
if (s.isSignedIn) AuthState.SignedIn(s) else AuthState.SignedOut
}
.catch { e ->
// IOException and anything the corruption handler did not absorb
Log.w("Auth", "Session unreadable: ${e::class.simpleName}") // never log the token
emit(AuthState.SignedOut)
}
Log the exception type, never the payload.
6.6 Process death between key creation and the first write
Key creation and the DataStore write are two separate durable operations. If the process dies between them you have an orphaned key and an empty file, which is harmless. The opposite order is the dangerous one: a file that exists without a key. Because we create the key lazily inside encrypt, the key always exists before the first ciphertext does. Preserve that ordering if you refactor.
7. Hardening checklist
- Key is AES-256, GCM, non-exportable, with a versioned alias.
- IV generated by the provider, never caller-supplied.
- Backup and device-transfer rules (both the Android 12+ and the legacy format) exclude the encrypted file, and the Tink keyset preferences if you use Tink.
-
ReplaceFileCorruptionHandlerreturns a signed-out default. - No token in logs, crash breadcrumbs,
toString()of data classes, or analytics events. OverridetoStringonAuthSessionor use a wrapper type that redacts. - Crypto behind an interface, with a fake for JVM tests and a small instrumented test for the real Keystore.
- Refresh logic has a retry ceiling and a single-flight guard.
- Access tokens are short-lived, and the server can revoke refresh tokens.
A sample redacting toString for the model:
data class AuthSession(/* ... */) {
override fun toString() = "AuthSession(signedIn=$isSignedIn, expiresAt=$expiresAtEpochSeconds)"
}
(If you use @Serializable with the data class, keep the override; it does not affect serialization.)
8. Testing what you can and what you cannot
-
JVM unit tests: exercise
EncryptedAuthSerializerwithFakeTokenCipher. Assert that corrupt bytes surface asCorruptionException, and that an empty file yields the default value. -
Instrumented tests: the real
AndroidKeyStoreprovider only exists on a device or emulator. One round-trip test (encryptthendecrypt) plus one "delete the alias, expect decrypt to fail" test catches most regressions. -
Manual drills worth doing once: restore a backup onto a second device, change the lock screen method, and
kill -9the process during a token refresh.
🔚 Closing thoughts
DataStore solved the consistency problem that made SharedPreferences painful. It was never meant to solve confidentiality, and pretending otherwise is how tokens end up in forensic dumps. Putting a Keystore-backed AEAD in the serializer costs roughly a hundred lines and one interface, and it moves the real risk from "someone copied a file" to "someone is executing code in my process", a much harder attack.
But the code is the easy half. Ship the backup exclusions, fail closed on every decryption error, keep the IV out of your hands, and test the unhappy paths on a real device. Those are the parts that decide whether your users stay signed in or get bounced to the login screen at random.
🍎 Share Your Kotlin Code with iOS — Without Rewriting Your App
If “we’ll just build it twice, once per platform” describes how your team ships mobile, I’d recommend “Kotlin Multiplatform for Android Developers” — a 22-chapter, hands-on guide that goes past the sample-project demos and follows the shared module all the way to two app store submissions. No iOS or Swift background required.
It walks through the expect/actual mechanism and source set hierarchy, shared architecture, networking with Ktor, persistence with Room KMP and SQLDelight, Koin dependency injection, Flow and StateFlow with Swift interop (SKIE and Swift Export), Compose Multiplatform versus native SwiftUI, navigation, testing across two runtimes, dual-toolchain CI/CD with Linux and macOS runners, publishing a KMP library, and migrating an existing Android app module by module. Everything builds toward one capstone: an offline-first Expense/Budget Tracker, complete with a pending-sync queue, retry/backoff, and a deployment checklist for both stores.
- E-book: Available on Google Play
- Kindle Edition: Available on Amazon
- Topmate: Available on Topmate
- Also available in Paperback & Hardcover.
Originally published on Medium.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.




