Back to journal

SQLCipher in Flutter, Without the Headache

Encrypting your local database is a one-time, ten-minute decision. Getting the key management right is the actual work. Here's how Drimin does it — and the small traps that turn an afternoon into a week.

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:

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:

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.

Encrypt it once. Forget about it.

If you're building a privacy-first Flutter app, this is the floor — not the ceiling.

Our Network

Offline Finance
Trenziq
Premium Essentials
IBULUXE
Technology
VoBot Developers
Pharma
Plasma Biotech
NGO / CSR
Jigyasa Foundation
Travel & Hotels
PGH
Publications
Grasp