SQLCipher has been the boring, correct answer to "how do I encrypt a SQLite database on mobile" for over a decade. It is a transparent extension to SQLite that encrypts the entire database file using AES-256 in CBC mode (with an HMAC for integrity), derives a key from your passphrase using PBKDF2, and otherwise stays out of your way. Once configured, every page written to disk is ciphertext; every page read into memory is plaintext. You write the same SQL you would have written anyway.
The reason it has a reputation for being painful in Flutter has nothing to do with SQLCipher itself and everything to do with key management. Cipher choice is settled. Where the key lives is where most apps get it wrong.
Picking your stack
There are two reasonable paths in modern Flutter:
-
sqflite_sqlcipher— a drop-in fork of the popularsqfliteplugin that links SQLCipher instead of stock SQLite. Same API. You issue aPRAGMA keyat open time and you're done. -
drift+sqlcipher_flutter_libs— the type-safe query builder we use in Drimin, which lets you swap the underlying executor for one that opens an encrypted database. You get compile-time-checked SQL and codegen'd model classes on top of the same encryption guarantees.
Both are sound. Drimin uses Drift because the rest of the app benefits from typed
queries and codegen; if you have a small, hand-written DAO, sqflite_sqlcipher
is the lighter choice.
Generating the key correctly
The number-one mistake in Flutter SQLCipher tutorials is using a hard-coded or user-derived passphrase. Don't. A hard-coded passphrase is no encryption at all; a user passphrase forces a login screen onto an app that doesn't need one.
Generate a 256-bit random key on first launch, store it in the OS keystore, and use that key directly:
final storage = const FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true),
);
Future<String> loadOrCreateKey() async {
final existing = await storage.read(key: 'db_key');
if (existing != null) return existing;
final bytes = List<int>.generate(
32, (_) => _rng.nextInt(256));
final hex = bytes
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
await storage.write(key: 'db_key', value: hex);
return hex;
}
On Android, flutter_secure_storage backs onto EncryptedSharedPreferences
which in turn relies on the Android Keystore. On devices with a TEE or
StrongBox-backed keystore (the majority shipped in the last five years), the master
key wrapping your db_key is hardware-backed and non-exportable. An
attacker with a copy of the on-disk database file gets ciphertext. An attacker with
a copy of the encrypted preferences blob gets a wrapped ciphertext blob.
Use Dart's Random.secure(), not Random(). The former
sources from /dev/urandom; the latter is a non-cryptographic PRNG and
is wholly inappropriate for key material.
Opening the database
With Drift, the executor looks roughly like this:
QueryExecutor _open(String hexKey) {
return LazyDatabase(() async {
final dir = await getApplicationDocumentsDirectory();
final file = File(p.join(dir.path, 'drimin.db'));
return NativeDatabase(
file,
setup: (raw) {
raw.execute("PRAGMA key = \"x'$hexKey'\"");
raw.execute('PRAGMA cipher_page_size = 4096');
raw.execute('PRAGMA kdf_iter = 256000');
},
);
});
}
Two non-obvious points. First, when you pass a hex key, you must use the
x'…' syntax so SQLCipher treats it as raw bytes instead of running PBKDF2
on the ASCII characters. Get this wrong and your "encryption" silently runs a key
derivation on the literal string "deadbeef…". Second, set kdf_iter
explicitly so future upstream defaults don't quietly change your work factor.
Schema migrations are still fine
Drift migrations work unchanged; the encryption layer is transparent below the SQL
engine. The one wrinkle is that you cannot inspect the database from
adb shell with sqlite3; you'll need a SQLCipher-aware
client (the standalone sqlcipher binary, or Datlock/DB Browser
compiled with SQLCipher support). For day-to-day development this almost never
matters — you query through your repository layer, not raw shell — but it
catches people out the first time.
If your "encrypted" database can be opened by a stock sqlite3 binary,
it isn't encrypted. Always verify with a pull-and-inspect on a real device build.
Performance: closer to free than you'd think
AES-256 page encryption adds overhead, but on modern ARM devices with crypto extensions, it is largely lost in the noise of disk I/O. In Drimin's benchmarks (single-row inserts on a Pixel 6) the encrypted path runs at roughly 92–96% of plaintext throughput. For a hydration log writing a handful of rows per day, that difference is unmeasurable in real use.
The one place where overhead becomes visible is bulk operations — importing
thousands of rows in a single transaction. Use explicit transactions (Drift does
this for you with batch()) to amortise the page-cipher cost across
commits.
Backups and the don't-do-this list
A few mistakes we've seen in code reviews, all of which defeat the encryption:
- Leaving
android:allowBackup="true". Google's automatic cloud backup will exfiltrate the encrypted database and a copy of the EncryptedSharedPreferences blob to your project's Drive backup. The data is still encrypted, but you've made the threat model materially harder to reason about. Drimin setsallowBackup="false"and usesdata_extraction_rules.xmlto opt out of device-to-device transfer too. See Privacy-first by default. - Logging the key, even briefly, in a
print()for debugging. Once it hits logcat, treat it as compromised and rotate. - Writing the key to a file in the documents directory "for convenience." If it's on disk outside the keystore, you don't have encryption; you have obfuscation.
- Using
SharedPreferencesdirectly instead of EncryptedSharedPreferences. The plain prefs file is a JSON-flavoured world.
What happens on uninstall
Two things, both important. The database file is deleted along with the app's data directory. The Keystore alias backing the EncryptedSharedPreferences master key is also deleted. Even if someone had previously cloned the database file off the device, it is now permanently undecryptable — the wrapping key it depended on no longer exists.
Where this fits in the bigger picture
Local encryption is necessary but not sufficient. It pairs with two other decisions: shipping no network capability at all (covered in Privacy-first by default), and designing the app around the assumption that the device is the source of truth (covered in Offline-first as a feature, not a fallback). The trio is what makes a "we don't have your data" claim survive serious scrutiny.