On this page
On Android, your app's private storage is sandboxed from other apps. That's a strong baseline. But tokens can still leak through device backups, rooted or compromised devices, debug logs, or analytics. Defence in depth means encrypting secrets with keys the app can't export, and limiting what a stolen token can do.
Threat model first
| Threat | Mitigation |
|---|---|
| Other apps reading your files | App sandbox (default); never store tokens on shared storage |
| Tokens extracted from backups | Exclude token files from backup; encrypt with non-exportable Keystore keys |
| Rooted device or malware with root | Keystore-backed encryption raises the bar; short-lived access tokens limit damage |
| Leaks via logs, crash reports, analytics | Redact Authorization headers; never log tokens |
| Stolen refresh token | Server-side rotation and revocation; device binding where available |
What about EncryptedSharedPreferences?
For years, Jetpack Security's EncryptedSharedPreferences was the standard answer. The androidx.security:security-crypto library has since been deprecated. It still works in existing apps, but new code should use an alternative, typically DataStore with Tink-based encryption and a Keystore-wrapped key, and existing apps should plan a migration.
Implementation: encrypted token store
dependencies {
implementation("androidx.datastore:datastore:<version>")
implementation("com.google.crypto.tink:tink-android:<version>")
}/** AEAD backed by a Tink keyset that is itself encrypted with an Android Keystore master key. */
object TokenCrypto {
private const val KEYSET_NAME = "token_keyset"
private const val PREF_FILE = "token_keyset_prefs"
private const val MASTER_KEY_URI = "android-keystore://token_master_key"
fun aead(context: Context): Aead {
AeadConfig.register()
return AndroidKeysetManager.Builder()
.withSharedPref(context, KEYSET_NAME, PREF_FILE) // stores the ENCRYPTED keyset
.withKeyTemplate(KeyTemplates.get("AES256_GCM"))
.withMasterKeyUri(MASTER_KEY_URI) // Keystore key wraps the keyset
.build()
.keysetHandle
.getPrimitive(RegistryConfiguration.get(), Aead::class.java)
}
}@Serializable
data class AuthTokens(val accessToken: String, val refreshToken: String, val expiresAtEpochSec: Long)
class EncryptedTokensSerializer(private val aead: Aead) : Serializer<AuthTokens?> {
override val defaultValue: AuthTokens? = null
private val associatedData = "auth_tokens".toByteArray()
override suspend fun readFrom(input: InputStream): AuthTokens? {
val bytes = input.readBytes()
if (bytes.isEmpty()) return null
return runCatching {
Json.decodeFromString<AuthTokens>(aead.decrypt(bytes, associatedData).decodeToString())
}.getOrNull() // undecryptable (key lost) โ treat as signed out
}
override suspend fun writeTo(t: AuthTokens?, output: OutputStream) {
if (t == null) return // empty file = no tokens
output.write(aead.encrypt(Json.encodeToString(t).encodeToByteArray(), associatedData))
}
}
class TokenStore(context: Context) {
private val store = DataStoreFactory.create(
serializer = EncryptedTokensSerializer(TokenCrypto.aead(context)),
produceFile = { File(context.filesDir, "datastore/auth_tokens.enc") },
)
@Volatile private var cached: AuthTokens? = null // fast synchronous reads for interceptors
val tokens: Flow<AuthTokens?> = store.data.onEach { cached = it }
fun accessTokenBlocking(): String? = cached?.accessToken
suspend fun save(tokens: AuthTokens) { store.updateData { tokens } }
suspend fun clear() { store.updateData { null } }
}AndroidX has also introduced a dedicated DataStore + Tink integration artifact. Check its status (it was alpha at the time of writing) before adopting it instead of a custom serializer. Initialise the cache at startup, for example by collecting tokens in an application scope, before the first authenticated request.
Exclude secrets from backup
<data-extraction-rules>
<cloud-backup>
<exclude domain="file" path="datastore/auth_tokens.enc" />
<exclude domain="sharedpref" path="token_keyset_prefs.xml" />
</cloud-backup>
<device-transfer>
<exclude domain="file" path="datastore/auth_tokens.enc" />
<exclude domain="sharedpref" path="token_keyset_prefs.xml" />
</device-transfer>
</data-extraction-rules>Reference it with android:dataExtractionRules in the manifest, and provide equivalent fullBackupContent rules for Android 11 and lower. Keystore keys aren't backed up, so restored ciphertext would be undecryptable anyway, but excluding it avoids confusing states.
Token lifecycle best practices
- Short-lived access tokens (minutes), plus a refresh token with server-side rotation: each refresh issues a new refresh token and invalidates the old one.
- Refresh in one place: an OkHttp
Authenticator. See OkHttp Interceptors. - Logout clears everything: tokens, cached user data, and a server-side revocation call.
- Treat decryption failure as signed out. Keys can be invalidated, for example after some device security changes.
Variations
Banking-grade apps
Add Keystore keys requiring user authentication (biometric) for high-risk operations, plus Play Integrity checks on the server.
Firebase Auth
Firebase manages its own session storage. Request fresh ID tokens via the SDK rather than storing them yourself.
OAuth / OpenID Connect
Use AppAuth with PKCE in a Custom Tab for the login flow, then store the resulting tokens as above.
Common mistakes
- Plain SharedPreferences or DataStore for tokens.
- Hard-coded encryption keys in the APK, which aren't secret.
- Logging tokens or full request headers.
- Long-lived access tokens with no rotation.
- Storing passwords on the device instead of tokens.
Frequently asked questions
Is the app sandbox not enough?
It protects against other apps on a healthy device. Encryption adds protection for backups, device transfers and some compromised-device scenarios.
Can a rooted device still steal tokens?
A sufficiently privileged attacker can read memory while the app runs. Encryption raises the cost, and short-lived tokens plus server-side checks limit the damage.
Should I store tokens in the Keystore directly?
The Keystore stores keys, not arbitrary data. Store encrypted tokens elsewhere, using a Keystore key for encryption.
Conclusion
Store tokens encrypted with a non-exportable Android Keystore key (for example DataStore plus Tink), exclude them from backups, keep access tokens short-lived with rotating refresh tokens, refresh in one place, and never log them. Migrate away from the deprecated EncryptedSharedPreferences.
Next step: protect secrets beyond tokens in Secure API Keys and Secrets in Android Apps.
Official references
๐ฑ Apps built by Yunok Tech
Real Android apps on Google Play, free to download.
NotiVaultNotification history: recover dismissed notificationsGet it โธ
Walzi4K wallpapers for lock and home screenGet it โธ
Ready to build real Android applications?
Security is easiest to get right when it is part of how you build from the first screen. Strengthen the Android fundamentals that secure, privacy-respecting apps are built on.
Move beyond theory and strengthen your Android development skills with Yunoktech's Android course. Learn core concepts, practical implementation, modern Android development techniques, and the reasoning behind building maintainable applications.
Explore the Android course and take your next step as an Android developer.