Encrypting Mobile App Data with Keychain and Keystore Safely
For mobile developers shipping apps that handle tokens, PII, or cached business data. This guide shows exactly what belongs in iOS Keychain and Android Keystore, how to wire envelope encryption with concrete code, and which secrets or datasets must never be stored on-device.
TL;DR — Store only small secrets in iOS Keychain and Android Keystore-backed storage: refresh tokens, per-device encryption keys, and key identifiers. Encrypt larger local data with an app-generated AES key, wrap that key with Keychain/Keystore, and never store server master secrets, password equivalents, or plaintext PII locally if the app can function without it. Reading time: ~5 min
Goal
When you finish, your mobile app stores only minimal secrets in Keychain/Keystore, encrypts any required local app data with a device-protected key, and has a clear denylist of data that must not be written to local storage at all.
Prerequisites
- iOS app access with Xcode 16+ and an app target that can enable Keychain Sharing if needed
- Android app access with Android Studio Koala+ or Gradle-based builds targeting Android API 26+
- Swift 5.10+ or Kotlin 1.9+ in the app codebase
- A test device or emulator plus a real device for final validation; hardware-backed behavior differs from emulators
adbinstalled — check withadb version- On macOS for iOS verification:
xcrunavailable — check withxcrun --version - The list of data your app currently stores locally: tokens, cached API responses, SQLite rows, files, logs, crash breadcrumbs
- Decision from your backend/API team on token model: short-lived access token + revocable refresh token is the baseline
Steps
Step 1: Classify what you will store and what you will not
Use this table as the literal policy for your app.
| Data item | Store locally? | Where | Notes |
|---|---|---|---|
| Refresh token | Yes | Keychain / Keystore-backed encrypted prefs | Treat as a password equivalent |
| Access token | Prefer memory only | RAM; if unavoidable, same as refresh token | Expire quickly |
| Per-device AES data key | Yes | Keychain / Keystore | Use for envelope encryption |
| Cached API payload with PII | Only if offline requires it | Encrypted DB/file | Add TTL and delete on logout |
| User password | No | Nowhere | Exchange for token, then discard |
| Private API signing key from server | No | Nowhere | Keep server-side only |
| Full card PAN/CVV, recovery codes, seed phrases | No | Nowhere unless your product is explicitly built and audited for this | Most apps should never persist these |
| Analytics/debug logs containing tokens/PII | No | Nowhere | Redact before logging |
What you should see when this succeeds: you have a written allowlist of exactly 2-4 items that may be persisted locally.
Step 2: Store a small secret in iOS Keychain
Add this Swift code and use kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly unless you explicitly need migration to a new device from backup.
import Foundation
import Security
enum KeychainStore {
static func save(service: String, account: String, data: Data) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
kSecValueData as String: data
]
SecItemDelete(query as CFDictionary)
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else {
throw NSError(domain: NSOSStatusErrorDomain, code: Int(status))
}
}
static func load(service: String, account: String) throws -> Data {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
guard status == errSecSuccess, let data = item as? Data else {
throw NSError(domain: NSOSStatusErrorDomain, code: Int(status))
}
return data
}
}
try KeychainStore.save(service: "com.example.app.auth", account: "refresh_token", data: Data("rt_123".utf8))
What you should see when this succeeds: the app writes and reads the secret without NSOSStatusErrorDomain Code=-25300 (errSecItemNotFound) or Code=-34018 (entitlement/access-group issue).
Step 3: Store a small secret in Android Keystore-backed storage
Use Jetpack Security for encrypted shared preferences. This stores the master key in Android Keystore and encrypts preference values.
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
val prefs = EncryptedSharedPreferences.create(
context,
"secure_prefs",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
prefs.edit().putString("refresh_token", "rt_123").commit()
val token = prefs.getString("refresh_token", null)
What you should see when this succeeds: token is rt_123, and app startup does not throw android.security.keystore.KeyPermanentlyInvalidatedException or GeneralSecurityException.
Step 4: Encrypt larger local data with envelope encryption
Do not put blobs, JSON payloads, or SQLite rows directly into Keychain/Keystore. Generate one random AES key for app data, store that key in Keychain/Keystore, and use it to encrypt files or database fields.
import CryptoKit
let dataKey = SymmetricKey(size: .bits256)
let rawKey = dataKey.withUnsafeBytes { Data($0) }
try KeychainStore.save(service: "com.example.app.crypto", account: "data_key_v1", data: rawKey)
let plaintext = Data("customer_email=user@example.com".utf8)
let sealed = try AES.GCM.seal(plaintext, using: dataKey)
let encryptedBlob = sealed.combined!
import java.security.SecureRandom
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
val keyGen = KeyGenerator.getInstance("AES")
keyGen.init(256)
val dataKey: SecretKey = keyGen.generateKey()
val rawKey = dataKey.encoded
prefs.edit().putString("data_key_v1", android.util.Base64.encodeToString(rawKey, android.util.Base64.NO_WRAP)).commit()
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE, dataKey)
val iv = cipher.iv
val ciphertext = cipher.doFinal("customer_email=user@example.com".toByteArray())
What you should see when this succeeds: your database/file layer writes ciphertext, not readable JSON or plaintext strings.
Step 5: Delete local secrets on logout, token revoke, and account removal
Wire explicit deletion paths. Do not rely on app uninstall timing or OS cleanup.
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.example.app.auth",
kSecAttrAccount as String: "refresh_token"
]
let status = SecItemDelete(query as CFDictionary)
print(status)
prefs.edit().remove("refresh_token").remove("data_key_v1").commit()
context.deleteSharedPreferences("secure_prefs")
What you should see when this succeeds: the next app launch requires re-authentication and cannot decrypt previously encrypted local data.
Step 6: Block accidental plaintext storage and logging
Search the codebase for unsafe persistence and logs before release.
grep -RInE "localStorage|UserDefaults|SharedPreferences|NSLog|Log\.(d|i|e)|print\(" .
grep -RInE "token|authorization|email|ssn|password|secret" .
What you should see when this succeeds: any remaining hits are either redacted logs or non-sensitive fields; no refresh token, password, or PII is written to plaintext prefs, defaults, logs, or temp files.
Verify it works
Run these checks end to end on a debug build.
View as text
[User Login] ---- 1. gets refresh token ----> [App] [App] ---- 2. stores token/key ----> [Keychain/Keystore] [App] ---- 3. encrypts cache ----> [Local DB/File] [Logout] ---- 4. delete token/key ----> [Keychain/Keystore] [App Relaunch] ---- 5. decrypt fails, login required ----> [User]
iOS simulator/device: verify the app can read the stored secret, then logout and verify read fails.
xcrun simctl spawn booted log stream --level debug --style compact --predicate 'process == "YourApp"'
Expected success shape after logout-triggered read:
YourApp: Keychain read failed domain=NSOSStatusErrorDomain code=-25300
Android: inspect app-private files only on a debug build or emulator. Ciphertext should not be human-readable.
adb shell run-as com.example.app ls -R files shared_prefs databases
adb shell run-as com.example.app cat shared_prefs/secure_prefs.xml
Expected success shape:
<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
<map>
<string name="__androidx_security_crypto_encrypted_prefs_key_keyset__">12A901...</string>
<string name="AQAAAB...">ARc9k7kY...</string>
</map>
You should not see plaintext values like rt_123, email addresses, or bearer tokens anywhere in files, prefs, SQLite rows, or logs.
Common pitfalls
Storing the access token in plaintext app preferences
Mistake: writing bearer tokens to UserDefaults, plain SharedPreferences, or an unencrypted SQLite column.
Symptom: grep or adb shell run-as ... cat shows the token in clear text.
Fix: move the token to Keychain/Keystore-backed storage, or keep it memory-only if your refresh flow allows it.
Using migratable iOS accessibility when you wanted device-only storage
Mistake: using kSecAttrAccessibleAfterFirstUnlock instead of kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly for secrets that must not survive device migration.
Symptom: secrets restore onto a new device from backup when your threat model said they should not.
Fix: change the attribute to a ...ThisDeviceOnly variant and rotate the stored secret.
Assuming Android emulator behavior matches hardware-backed devices
Mistake: testing only on an emulator and concluding biometric enrollment changes or screen-lock changes do not affect keys.
Symptom: production devices throw KeyPermanentlyInvalidatedException after lock-screen reset or biometric changes.
Fix: test on at least one physical device and handle key invalidation by deleting the wrapped key and forcing re-authentication.
Putting large blobs directly into Keychain/Keystore-backed secret stores
Mistake: storing whole JSON documents, images, or database exports as Keychain items or encrypted prefs values.
Symptom: slow reads/writes, startup regressions, or intermittent failures on older devices.
Fix: store one 256-bit data key in Keychain/Keystore and encrypt bulk data in files/DB with AES-GCM.
Forgetting to wipe encrypted data when deleting the key
Mistake: deleting only the token/key but leaving encrypted local files forever.
Symptom: storage bloat and undecryptable orphaned data after logout or account deletion.
Fix: delete both the wrapped key and the encrypted files/rows in the same logout/account-removal transaction.
Logging secrets during debugging
Mistake: printing headers, request bodies, or exception objects that contain tokens or PII.
Symptom: device logs, crash reports, or CI artifacts contain Authorization: Bearer ... or user data.
Fix: redact before logging and block these fields in your logger middleware/interceptors.
This article was written by an AI system and published pending human review. Verify anything you intend to act on.
Have a project in mind?
Get an instant AI price estimate for it, or talk directly to our team.
One email a month on what we learn building with AI