Implementing local data encryption in mobile applications
SQLite database without encryption on Android — it's just a file. adb pull /data/data/com.yourapp/databases/app.db on a rooted device, and all user data is readable by any SQLite browser. On iOS the situation is slightly better due to Data Protection API, but only if the developer didn't forget to set the correct NSFileProtectionKey — and this is forgotten often.
What and how we encrypt
The task is divided into three independent layers: database, files, secrets.
Database. Standard — SQLCipher. SQLite fork with transparent AES-256 encryption at page level. On Android connected via net.zetetic:android-database-sqlcipher, on iOS via SQLCipher.xcframework. Room on Android can work with SQLCipher via SupportOpenHelperFactory — switching takes literally replacing the factory in Room.databaseBuilder() and adding the key. The key is generated once, stored in Keystore/Keychain, never stored in SharedPreferences or UserDefaults in open form.
On first launch on Android:
val key = generateAes256Key() // via KeyGenerator with KeyStore provider
val encryptedKey = encryptWithKeystore(key) // RSA/AES via AndroidKeyStore
prefs.putString("db_key_enc", Base64.encode(encryptedKey))
Then each time you open the database, decrypt the key and pass it to SQLCipher. Without PRAGMA key the database simply doesn't open.
Files. For images, PDFs, cache — AES-256-GCM via javax.crypto.Cipher on Android or CryptoKit.AES.GCM on iOS (Swift 5.5+). GCM is important: it provides both confidentiality and integrity authentication. CBC without MAC — bad choice, vulnerable to padding attacks.
On Flutter, flutter_secure_storage for secrets and encrypt for files are convenient, but under the hood both use the same native API — wrappers, not replacement.
Secrets (API keys, tokens). Only Keychain (iOS) and Android Keystore. Not UserDefaults, not SharedPreferences, not AsyncStorage in React Native. Keychain on iOS is encrypted with keys tied to Secure Enclave; Keystore on Android with API 23+ ties keys to TEE or SE — can't export them even with root.
Typical mistakes that break the entire scheme
First — wrong file protection class on iOS. FileProtectionType.complete means: file is inaccessible while device is locked. But if app receives push notification in background and tries to read database — crash. Developers in panic switch to completeUnlessOpen or remove protection entirely. Correct solution — split data: critical under .complete, background operations under .completeUnlessOpen.
Second — storing the key next to the data. I've seen cases where database encryption key was in the same directory as encrypted database, just in file key.bin. This is not encryption, this is renaming.
Third — using user password directly as key. AES requires 128 or 256 bits. Password "qwerty123" — not a key. Need KDF: PBKDF2 with minimum 100,000 iterations or Argon2id. On iOS — CommonCrypto.CCKeyDerivationPBKDF, on Android — SecretKeyFactory with PBKDF2WithHmacSHA256.
Integration with biometrics
Advanced variant — encryption key for database protected by biometrics via Keystore/Keychain. On Android: KeyGenParameterSpec.Builder with .setUserAuthenticationRequired(true) and .setUserAuthenticationParameters(0, KeyProperties.AUTH_BIOMETRIC_STRONG). Key is created once on first login with biometrics, then each time app opens user authenticates, key unlocks from Keystore, database opens.
On iOS similarly via kSecAttrAccessControl with SecAccessControlCreateWithFlags and flag .biometryAny or .biometryCurrentSet. Difference: .biometryCurrentSet invalidates key on new fingerprint addition — important for banking apps.
Process
Start with inventory: what and where is stored, is there already encryption, what data falls under requirements (PCI DSS, GDPR, local regulations). Next — choose key scheme, implement encryption layer, integrate with existing storage. Separately — test scenarios: app update, restore from backup, user changes biometrics.
Timeline depends on data volume and existing storage scheme. If database exists and needs migration to SQLCipher — 3–5 days including testing. File cache encryption and Keychain integration — additional 1–2 days. Full scheme from scratch for new project — faster.







