Skip to content
All posts
August 4, 20267 min read

Securely Storing Secrets in Android Keystore

Learn how to use Android Keystore to protect API keys, tokens, and passwords in your Kotlin apps, with step‑by‑step code, best‑practice patterns, and common pitfalls to avoid.

AndroidKotlinSecurity
Share:

Hardcoding secrets in source code is a ticking time bomb. Every leaked API key or token can lead to data breaches, unauthorized billing, or worse. Android Keystore gives you a hardware‑backed vault that keeps cryptographic material out of reach from malware and rooted devices.

Why Android Keystore Matters

Mobile apps constantly need to store sensitive values: backend API keys, JWT tokens, encryption keys for local data, or third‑party SDK credentials. Storing these in plain SharedPreferences, files, or even encrypted with a static password is insecure because the protection key itself becomes discoverable through decompilation or runtime inspection.

Android Keystore solves this by never exposing the raw key material to the application layer. Keys are generated, used, and deleted inside the Trusted Execution Environment (TEE) or Secure Element, depending on device capabilities. The app only receives encrypted blobs or can request signatures/encryptions without ever seeing the key.

[!NOTE]
On devices lacking a TEE, Keystore falls back to software‑based encryption, but the key still remains inaccessible via the Android API, raising the bar for attackers.

Threat Model

ThreatPlain SharedPreferencesEncrypted File (static password)Android Keystore
Decompiled APKKey visiblePassword visible → key derivableKey never exposed
Rooted deviceKey readablePassword may be extractedKey material stays in TEE
Malware with rootDirect readPossible if password sniffedRequires hardware compromise
Backup extractionExportedExported (if password stored)Not exportable without user auth

The table shows why Keystore is the only option that protects the key itself, not just the data it encrypts.

Setting Up Keystore in Kotlin

Below is a minimal, production‑ready wrapper that generates an AES key, encrypts a string, and later decrypts it. All cryptographic operations happen inside Keystore; the app only handles ciphertext.

kotlin
/**
 * KeystoreHelper – singleton wrapper for AES‑GCM encryption.
 * Uses Android Keystore to store a symmetric key that never leaves the TEE.
 */
object KeystoreHelper {

    private const val KEY_ALIAS = "com.sudarshantechlabs.app.master_key"
    private const val TRANSFORMATION = "AES/GCM/NoPadding"

    private val keyStore: KeyStore by lazy {
        KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
    }

    private val keyGenerator: KeyGenerator by lazy {
        KeyGenerator.getInstance(
            KeyProperties.KEY_ALGORITHM_AES,
            "AndroidKeyStore"
        ).apply {
            init(
                KeyGenParameterSpec.Builder(KEY_ALIAS, 
                    KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
                    .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
                    .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
                    .setRandomizedEncryptionRequired(true)
                    .setUserAuthenticationRequired(false) // set true if you want biometric lock
                    .build()
            )
        }
    }

    /** Call once at app start to ensure the key exists. */
    fun init() {
        if (!keyStore.containsAlias(KEY_ALIAS)) {
            keyGenerator.generateKey()
        }
    }

    /** Encrypt a plaintext string, returning Base64‑encoded ciphertext || iv. */
    fun encrypt(plainText: String): String {
        val secretKey = keyStore.getKey(KEY_ALIAS, null) as SecretKey
        val cipher = Cipher.getInstance(TRANSFORMATION).apply {
            init(Cipher.ENCRYPT_MODE, secretKey)
        }
        val iv = cipher.iv ?: throw IllegalStateException("IV missing")
        val cipherText = cipher.doFinal(plainText.toByteArray(Charsets.UTF_8))
        // Prepend IV for storage; both are needed for decryption
        val combined = iv + cipherText
        return Base64.encodeToString(combined, Base64.NO_WRAP)
    }

    /** Decrypt a ciphertext produced by [encrypt]. */
    fun decrypt(combinedBase64: String): String {
        val combined = Base64.decode(combinedBase64, Base64.NO_WRAP)
        val ivSize = 12 // GCM recommended IV length
        val iv = combined.copyOfRange(0, ivSize)
        val cipherText = combined.copyOfRange(ivSize, combined.size)

        val secretKey = keyStore.getKey(KEY_ALIAS, null) as SecretKey
        val cipher = Cipher.getInstance(TRANSFORMATION).apply {
            init(Cipher.DECRYPT_MODE, secretKey, IvParameterSpec(iv))
        }
        val plainBytes = cipher.doFinal(cipherText)
        return String(plainBytes, Charsets.UTF_8)
    }
}

Usage example

kotlin
fun main() {
    KeystoreHelper.init()
    val token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
    val encrypted = KeystoreHelper.encrypt(token)
    // Store `encrypted` in SharedPreferences, Room, or a file
    val decrypted = KeystoreHelper.decrypt(encrypted)
    assert(token == decrypted)
}

[!TIP]
Store only the ciphertext (or the Base64 string) in SharedPreferences or Room. Never persist the IV separately unless you prepend it as shown; losing the IV makes decryption impossible.

Key Lifecycle Considerations

  • Generation: Run
    code
    init()
    once, preferably in
    code
    Application.onCreate()
    . If the key already exists, the call is a no‑op.
  • Rotation: To rotate, generate a new alias, re‑encrypt all existing data with the new key, then delete the old alias. Keystore does not support in‑place key replacement.
  • Deletion: Call
    code
    keyStore.deleteEntry(KEY_ALIAS)
    when you no longer need the key (e.g., on logout). This renders any previously encrypted data unrecoverable—a useful property for sensitive sessions.

Best Practices and Common Mistakes

Even with a strong API, misuse can weaken security. Below are concrete do’s and don’ts derived from real‑world audits.

Do

  1. Bind keys to user authentication when protecting high‑value data (e.g., payment tokens). Set
    code
    setUserAuthenticationRequired(true)
    and optionally
    code
    setUserAuthenticationValidityDurationSeconds
    . This forces the user to authenticate via PIN, pattern, or biometrics before the key can be used.
  2. Limit key usage to the cryptographic operations you need. If you only need encryption, do not request
    code
    PURPOSE_SIGN
    .
  3. Handle exceptions gracefully.
    code
    KeyPermanentlyInvalidatedException
    signals that the key was reset (e.g., after a device lockout). Treat it as a prompt to re‑authenticate and regenerate the key.
  4. Prefer GCM mode with a random IV per encryption. Avoid ECB or CBC without proper MAC; GCM provides confidentiality and integrity.

Don’t

  1. Hard‑code the alias or key material in source code. The alias can be public, but never embed a predetermined key.
  2. Encrypt large blobs directly with Keystore. AES‑GCM has a practical limit (~16 MB per operation). For files, encrypt a random symmetric key with Keystore, then use that key with
    code
    Cipher
    for bulk data.
  3. Assume all devices have hardware backup. On low‑end Android Go devices, Keystore may be software‑based. Consider augmenting with a server‑side secret for critical secrets.
  4. Store the encrypted data alongside the IV in separate locations without binding them. If an attacker can swap IVs, they may induce padding‑oracle style attacks (though GCM resists them, mismatched IVs still break decryption).

[!WARNING]
Never rely on Keystore alone to protect data if the device is compromised via a firmware exploit. Treat it as a layer in defense‑in‑depth, not a silver bullet.

Integrating with Jetpack Compose & ViewModel

A typical architecture places the Keystore helper inside a

code
Repository
that a
code
ViewModel
consumes. Below is a concise Compose UI that lets the user store and retrieve a secret token.

kotlin
@Composable
fun SecretTokenScreen(viewModel: SecretTokenViewModel = hiltViewModel()) {
    val tokenState by viewModel.token.collectAsState()
    val loading by viewModel.isLoading.collectAsState()

    Column(modifier = Modifier.padding(16.dp)) {
        TextField(
            value = tokenState ?: "",
            onValueChange = { viewModel.updateToken(it) },
            label = { Text("API Token") },
            isError = tokenState.isNullOrEmpty(),
            placeholder = { Text("Enter token") }
        )
        Spacer(modifier = Modifier.height(8.dp))
        if (loading) {
            CircularProgressIndicator(modifier = Modifier.align(Alignment.CenterHorizontally))
        } else {
            Button(
                onClick = { viewModel.saveToken() },
                enabled = tokenState.isNotBlank()
            ) {
                Text("Save Securely")
            }
        }
    }
}
kotlin
@HiltViewModel
class SecretTokenViewModel @Inject constructor(
    private val repository: SecretRepository
) : ViewModel() {

    private val _token = MutableStateFlow<String?>(null)
    val token: StateFlow<String?> = _token.asStateFlow()
    private val _isLoading = MutableStateFlow(false)
    val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()

    fun updateToken(newToken: String) {
        _token.value = newToken
    }

    fun saveToken() {
        val token = _token.value ?: return
        viewModelScope.launch {
            _isLoading.value = true
            try {
                repository.storeToken(token)
                _token.value = null // clear UI after success
            } finally {
                _isLoading.value = false
            }
        }
    }
}

The repository simply delegates to

code
KeystoreHelper
:

kotlin
class SecretRepository @Inject constructor() {
    fun storeToken(token: String) {
        val encrypted = KeystoreHelper.encrypt(token)
        // PreferenceDao is a thin wrapper around DataStore or SharedPreferences
        preferenceDao.saveEncryptedToken(encrypted)
    }

    fun getToken(): String? {
        val encrypted = preferenceDao.getEncryptedToken()
        return encrypted?.let { KeystoreHelper.decrypt(it) }
    }
}

[!IMPORTANT]
Always clear plaintext secrets from memory as soon as they are no longer needed. In Kotlin, overwrite mutable

code
ByteArray
or
code
CharArray
with zeros after use, or use
code
apply { fill(0) }
for arrays.

Key Takeaways

  • Use Android Keystore for any symmetric or asymmetric key that protects user data, API tokens, or credentials. The key never leaves the TEE, making extraction far harder than with file‑based encryption.
  • Generate the key once per app lifecycle (or per user session) and reuse it for encryption/decryption. Rotate only when you have a clear re‑encryption plan; otherwise, stick with a stable alias.
  • Never store raw secrets or the key itself in SharedPreferences, assets, or code. Store only the ciphertext (with IV prefixed) and rely on Keystore to guard the decryption key.
  • Combine Keystore with user‑authentication‑bound keys for high‑value secrets (e.g., payment tokens) to add a second factor that requires biometric or PIN verification before the key can be used.
  • Handle edge cases:
    code
    KeyPermanentlyInvalidatedException
    , missing IV, and devices lacking hardware backup. Fail closed—prompt the user to re‑authenticate or regenerate the key.

By following the patterns above, you’ll move from “hoping no one decompiles my APK” to “knowing that even a rooted phone cannot pull my encryption keys out of the device’s secure vault.” Start integrating Keystore today, and sleep a little easier knowing your users’ secrets are guarded by hardware, not hope.

Share:
S

Sudarshan Chaudhari

AI Systems Builder / Product Engineer

Bangkok, Thailand

Solo Android developer with 13+ years in QA, building Android apps, AI automation systems, and developer tools at SudarshanTechLabs.

Stay updated

Get new posts on Android, Kotlin, and solo dev straight to your inbox.

Newsletter preferences

Related Apps

MyFamilyTracker

Real-time family location sharing — Firebase Realtime DB for sub-second propagation, WorkManager + ForegroundService for OS-compliant background collection, geofencing via Google Maps API.

Building something? Available for Android dev and QA consulting.

Work with me

Comments — powered by Giscus

Apps tagged with this