A token can be encrypted on disk and still leak through a debug log, a support screenshot, or an AI prompt.
Mobile security needs two boundaries: how data is protected while stored, and where plaintext is allowed to travel after the app reads it. Android Keystore and iOS Keychain help with the first. The second is an application design decision.
First, separate keys, secrets, and bulk data
| What you have | A useful storage direction |
|---|---|
| Android encryption key | Generate and use it through Android Keystore |
| iOS token or small secret | Keychain item with explicit accessibility policy |
| Android token | Authenticated ciphertext in private storage, protected by an appropriate Keystore-managed key |
| Documents, attachments, large caches | Encrypted files or a suitable encrypted database, with separately protected key material |
| Shared AI-provider credential | Backend secret manager; do not ship it in the mobile app |
Keystore is primarily a key-management facility. Keychain is also a store for small secrets. Neither should be treated as a place to dump an entire application database.
Android: protect the key, then encrypt the value
Android Keystore can keep key material non-exportable and constrain how a key is used. Hardware-backed protection depends on the device, algorithm, and configuration; do not assume every generated key lives in StrongBox.
This Kotlin snippet creates a new AES key for a new alias on API 23+:
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
fun createNewStorageKey(alias: String): SecretKey {
val generator = KeyGenerator.getInstance(
KeyProperties.KEY_ALGORITHM_AES,
"AndroidKeyStore",
)
generator.init(
KeyGenParameterSpec.Builder(
alias,
KeyProperties.PURPOSE_ENCRYPT or
KeyProperties.PURPOSE_DECRYPT,
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(256)
.build()
)
return generator.generateKey()
}
This is key creation, not a complete storage wrapper. Do not call it on every read or silently replace an existing alias: old ciphertext still depends on its original key. Serialize first-use initialization and load the existing key for subsequent operations.
Use a platform-supported authenticated encryption construction, such as AES/GCM/NoPadding. On encryption, let the provider generate a fresh IV and persist the IV with the ciphertext and authentication tag. Never reuse a GCM nonce with the same key. Authenticate on decryption and reject modified data.
An ordinary Keystore key is not automatically gated by a new biometric prompt. Configure authentication requirements deliberately when the use case needs them, and design for lock state, cancellation, and key invalidation. A compromised process may still invoke allowed key operations even when it cannot extract the key.
iOS: store a small secret with an explicit access policy
For a foreground-only credential that should not migrate to another device, a Keychain add operation can look like this:
import Foundation
import Security
func addNewSecret(
_ value: Data,
service: String,
account: String
) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecAttrAccessible as String:
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
kSecValueData as String: value,
]
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else {
throw NSError(domain: NSOSStatusErrorDomain, code: Int(status))
}
}
This intentionally adds only a new item. Handle errSecDuplicateItem using a narrowly matched SecItemUpdate when updating is intended; do not delete and recreate a credential on every save. Define service/account namespaces and access groups deliberately.
Choose accessibility around actual behavior:
-
WhenUnlockedThisDeviceOnly: available while unlocked; does not migrate to a different device through backup restoration. -
AfterFirstUnlockThisDeviceOnly: can support access while locked after the first unlock since restart, with a broader availability window. -
WhenPasscodeSetThisDeviceOnly: requires a device passcode; removing it deletes items in this class.
ThisDeviceOnly is a migration restriction, not a claim that the item can never appear in a same-device backup. Explicitly define synchronization and recovery requirements.
If user presence is required at retrieval, use Keychain access controls tied to the operation. A separate Face ID screen does not automatically protect every later read. Also, Keychain is not synonymous with Secure Enclave: placing an arbitrary AES key in Keychain does not make it a non-exportable Secure Enclave AES key.
Treat errSecItemNotFound, temporary interaction restrictions, and authentication cancellation differently. Deleting a credential because a locked-device read failed can turn a recoverable condition into data loss.
What if the app has much more data?
For larger datasets, protect the data where it belongs—files or a database—and protect the keys separately.
A possible envelope-encryption design is:
Platform-protected key material
|
v
protects a random data-encryption key
|
v
authenticated encrypted files / database content
On Android, a non-exportable Keystore key can wrap a random data key. On iOS, a symmetric data key can be stored as a protected Keychain item. These mechanisms have different properties; an iOS symmetric key retrieved into the process is still present in application memory.
Record a versioned format, algorithm, key identifier, nonce, ciphertext, and tag as appropriate. Associated data can bind ciphertext to an expected account/record context, but is authenticated rather than encrypted. Use trusted context during decryption; merely trusting metadata supplied alongside an attacker's ciphertext does not enforce account isolation.
For large files, use a vetted streaming authenticated-encryption implementation rather than inventing chunking, nonce allocation, or tag rules. Google's Tink Streaming AEAD is one option for supported environments; its Objective-C implementation does not offer the same streaming primitive. Verify platform support and the file format before promising Android/iOS interoperability.
For a database, evaluate a maintained encryption implementation, including its journals, write-ahead logs, temporary files, and key-handling behavior. Room and DataStore do not automatically provide application-level encryption simply because the app uses them. Platform file protection is useful, but does not replace your chosen key and backup policy.
Encryption at rest does not authorize a server request, hide plaintext from your own running code, or stop every attack on a compromised device.
The AI-era failure happens after decryption
Imagine an assistant that summarizes a customer's document. The app decrypts it correctly, then uploads the entire text and a diagnostic token to a model endpoint. Local encryption worked; the data-disclosure boundary failed.
Make that boundary explicit:
- Keep shared provider credentials on your backend. A key embedded in an APK or IPA is distributable to the person running the app, even if obfuscated or encrypted with another bundled secret.
- Authenticate users, enforce authorization, limit spending/rate, and constrain operations server-side. For direct provider access, use purpose-built, short-lived, narrowly scoped credentials where supported.
- Send only the content required for the requested feature. Prefer synthetic/redacted examples when debugging with AI tools; exclude tokens, private keys, customer records, and production dumps.
- Check provider retention, training use, access, and deletion controls for the actual account and service. Do not assume every endpoint has the same policy.
- Treat retrieved text and model output as untrusted. Enforce permissions in application/tool code, not in a prompt saying “never reveal secrets.”
- Review prompt traces, crash reports, analytics, clipboard actions, and support exports. They can bypass otherwise careful storage design.
For local models, the network disclosure risk may change, but prompt history, caches, embeddings, logs, and tool permissions still need protection. “On device” is a location, not a complete security policy.
Plan the moments when keys disappear
Decide what happens after uninstall, restore to a new device, account removal, or key invalidation. Device-bound keys and restored ciphertext may not remain usable together. Test backup exclusions and recovery instead of discovering the mismatch after release.
On logout, revoke server sessions where appropriate and remove account-scoped secrets and cached plaintext. Key deletion can render ciphertext unusable, but is not proof that every backup, exported copy, or flash-storage remnant vanished. Recovery, retention, and deletion are separate requirements.
Before shipping, verify tampered ciphertext is rejected, nonce handling survives repeated writes, locked-device reads fail safely, accounts cannot read each other's records, and diagnostic/AI flows never receive unintended plaintext.
Protect the key, minimize the plaintext lifetime, and make every boundary after decryption intentional.
References
- Android Developers: Android Keystore system
- Android Developers: Cryptography
- Apple: Keychain services
- Apple: Restricting Keychain item accessibility
- Apple: SecItem pitfalls and best practices
- Apple: Protecting keys with the Secure Enclave
- Apple: AES.GCM nonce requirements
- Google Tink: Encrypt large files or data streams
- Google Tink: Supported key types by language
- OWASP: Sensitive information disclosure
- OWASP: System prompt leakage
Top comments (0)