Storing LLM credentials on an Android device demands more than a hardcoded string or a plaintext SharedPreferences file. Proper android datastore api key storage combines Jetpack DataStore for structured persistence with the Android Keystore system for encryption, so a rooted or extracted filesystem image still yields ciphertext. This guide walks through a complete implementation in Kotlin, from Gradle dependencies to a working network client that reads the key at runtime and clears it on logout.
Why not just SharedPreferences?
SharedPreferences writes XML to data/data/package/shared_prefs. Any process with read permission (or a backup extract) sees the raw value. DataStore is built on Kotlin coroutines and protobuf, avoids synchronous IO on the UI thread, and gives you a Flow of changes. It still writes plaintext protobuf unless you encrypt the bytes yourself. That is the gap we close here.
Step 1: Add the required dependencies
Use Gradle version catalogs or direct declarations. You need DataStore Preferences and Jetpack Security (for key generation) or just use AndroidKeyStore directly. We’ll use the security-crypto library to simplify master key handling.
dependencies {
implementation "androidx.datastore:datastore-preferences:1.1.1"
implementation "androidx.security:security-crypto:1.1.0-alpha06"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3"
implementation "com.squareup.retrofit2:retrofit:2.9.0"
implementation "com.squareup.okhttp3:okhttp:4.12.0"
// If using Hilt
implementation "com.google.dagger:hilt-android:2.48"
kapt "com.google.dagger:hilt-compiler:2.48"
}
Sync and ensure minSdk 23+ (AES-GCM in Keystore is reliable from M onward).
Step 2: Build an encryption wrapper around Android Keystore
DataStore does not encrypt by default. We’ll store the API key as an encrypted string. Create a CryptoBox object that generates an AES key in AndroidKeyStore and provides encrypt/decrypt.
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
import java.security.KeyStore
import android.util.Base64
object CryptoBox {
private const val KEY_ALIAS = "llm_key_alias"
private const val ANDROID_KEYSTORE = "AndroidKeyStore"
private const val TRANSFORMATION =
"${KeyProperties.KEY_ALGORITHM_AES}/${KeyProperties.BLOCK_MODE_GCM}/${KeyProperties.ENCRYPTION_PADDING_NONE}"
private fun getKey(): SecretKey {
val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
return if (keyStore.containsAlias(KEY_ALIAS)) {
(keyStore.getEntry(KEY_ALIAS, null) as KeyStore.SecretKeyEntry).secretKey
} else {
val gen = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE)
gen.init(
KeyGenParameterSpec.Builder(
KEY_ALIAS,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
).setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setRandomizedEncryptionRequired(true)
.build()
)
gen.generateKey()
}
}
fun encrypt(plaintext: String): String {
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.ENCRYPT_MODE, getKey())
val iv = cipher.iv
val ciphertext = cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8))
val combined = iv + ciphertext
return Base64.encodeToString(combined, Base64.NO_WRAP)
}
fun decrypt(store: String): String {
val combined = Base64.decode(store, Base64.NO_WRAP)
val iv = combined.copyOfRange(0, 12)
val ct = combined.copyOfRange(12, combined.size)
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.DECRYPT_MODE, getKey(), GCMParameterSpec(128, iv))
return String(cipher.doFinal(ct), Charsets.UTF_8)
}
}
This wrapper uses AES-GCM with a random 12-byte IV per encryption. The secret key never leaves Keystore. If decryption fails (e.g., key purged after uninstall), catch CryptoException and force re-login.
Step 3: Define a DataStore instance
Create a singleton or Hilt-qualified DataStore<Preferences>. We’ll store the encrypted key under a string preference.
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import android.content.Context
private val Context.dataStore by preferencesDataStore(name = "secure_llm_prefs")
object ApiKeyPrefs {
val ENCRYPTED_KEY = stringPreferencesKey("encrypted_llm_api_key")
}
If you use Hilt, provide Context and the dataStore extension via a module.
Step 4: Write the API key to DataStore
When the user logs in or enters a key from settings, encrypt and persist it.
import androidx.datastore.preferences.core.edit
import kotlinx.coroutines.flow.first
suspend fun saveApiKey(context: Context, rawKey: String) {
val encrypted = CryptoBox.encrypt(rawKey)
context.dataStore.edit { prefs ->
prefs[ApiKeyPrefs.ENCRYPTED_KEY] = encrypted
}
}
Call from a ViewModel:
class SettingsViewModel(app: Application) : AndroidViewModel(app) {
fun storeKey(key: String) = viewModelScope.launch {
saveApiKey(getApplication(), key)
}
}
Step 5: Read the API key at runtime
Expose a Flow that emits the decrypted key. Prefer a Flow so the client always has the latest key after rotation.
fun apiKeyFlow(context: Context): Flow<String?> = context.dataStore.data
.map { prefs ->
prefs[ApiKeyPrefs.ENCRYPTED_KEY]?.let { enc ->
try { CryptoBox.decrypt(enc) } catch (e: Exception) { null }
}
}
suspend fun getApiKey(context: Context): String? =
apiKeyFlow(context).first()
Never log the decrypted value in production builds.
Step 6: Attach the key to LLM requests
Most LLM gateways expose an OpenAI-compatible REST API. Use OkHttp with an auth interceptor that pulls the key from DataStore.
import okhttp3.Interceptor
import okhttp3.Response
import kotlinx.coroutines.runBlocking
class ApiKeyInterceptor(private val context: Context) : Interceptor {
override fun intercept(chain: Chain): Response {
val key = runBlocking { getApiKey(context) }
?: return chain.proceed(chain.request())
val req = chain.request().newBuilder()
.addHeader("Authorization", "Bearer $key")
.build()
return chain.proceed(req)
}
}
If you route through a unified gateway such as n4n.ai, which offers a single OpenAI-compatible endpoint covering 240+ models with automatic fallback when a provider is degraded, you only store one project key via android datastore api key storage instead of per-provider secrets.
Build Retrofit:
val retrofit = Retrofit.Builder()
.baseUrl("https://api.example-gateway.com/v1/")
.client(OkHttpClient.Builder().addInterceptor(ApiKeyInterceptor(appContext)).build())
.addConverterFactory(MoshiConverterFactory.create())
.build()
Step 7: Clear keys on logout or key rotation
DataStore makes removal trivial.
suspend fun clearApiKey(context: Context) {
context.dataStore.edit { it.remove(ApiKeyPrefs.ENCRYPTED_KEY) }
}
Also consider rotating the Keystore alias periodically; you would re-encrypt with a new alias and delete the old.
Step 8: Handle migration from plaintext
If you shipped a version with plaintext SharedPreferences, migrate on first launch:
suspend fun migrateIfNeeded(context: Context) {
val legacy = context.getSharedPreferences("legacy", 0).getString("api_key", null)
if (!legacy.isNullOrEmpty()) {
saveApiKey(context, legacy)
context.getSharedPreferences("legacy", 0).edit().remove("api_key").apply()
}
}
Run this before any network call, ideally in an Initializer or Application.onCreate coroutine.
Verify the implementation
You need proof that the key is encrypted at rest and readable in-app.
- Check ciphertext in App Data: Use Android Studio’s Device File Explorer. Navigate to
/data/data/your.package.name/files/datastore/secure_llm_prefs.preferences_pb. Pull the file and inspect; the API key string should be base64 ciphertext, not the raw key. - Runtime assertion: Write an instrumented test:
@Test
fun key_roundtrip() = runTest {
val ctx = ApplicationProvider.getApplicationContext<Context>()
saveApiKey(ctx, "sk-test-123")
val out = getApiKey(ctx)
assertEquals("sk-test-123", out)
clearApiKey(ctx)
assertNull(getApiKey(ctx))
}
- Network trace: With OkHttp logging interceptor, confirm the
Authorizationheader is present and that the key is not in the request URL.
Threat model and limitations
Android Keystore protects against offline extraction on devices with verified boot and lock screen. It does not stop a malicious app with root from calling your app’s memory, nor does it replace TLS. Use certificate pinning for the gateway. If the device lacks a secure hardware backing, Keystore falls back to software; treat that as best-effort.
For LLM integrations, avoid bundling provider keys in the APK. Proxy through a backend or a gateway that issues short-lived tokens, and store only that token via android datastore api key storage.
Alternative: EncryptedSharedPreferences
If you don’t need DataStore’s reactive Flow or proto support, EncryptedSharedPreferences from security-crypto is less code. But DataStore’s coroutine API integrates cleaner with modern Android architecture. The encryption principle remains identical: a Keystore-backed master key.
Final checklist
- DataStore dependency added.
- Keystore AES-GCM wrapper implemented.
- Key stored as encrypted string in DataStore.
- Interceptor attaches header at runtime.
- Logout clears preference.
- Instrumented test passes.
Following these steps gives you defensible android datastore api key storage that survives process death, respects scoped storage, and keeps LLM credentials out of plaintext XML.