
A green build is not a security guarantee
If you add an encryption package to your Flutter app, it resolves, it builds, the APK ships — most people would call that done. We were adding at-rest encryption to the SQLite database inside a Flutter finance app, and the obvious dependency for the job behaved exactly like that. Resolved cleanly. Built cleanly. No warnings.
It also left the database in plaintext.
That is the trap worth knowing about, because it survives every check a normal review does. The two packages most people reach for — sqlcipher_flutter_libs (0.7.0+eol) and sqlite3_flutter_libs (0.6.0+eol) — are, per their own READMEs, empty packages. The +eol is the tell: end of life. They’ve been deprecated into no-ops. They still publish, still resolve, still get pulled into your build graph without complaint. A pubspec.yaml review shows an encryption library present and pinned, and everything about that line says “encryption is on.” Nothing about it is true at runtime.
Why this one gets past people
Most broken dependencies fail loudly. You get a resolution conflict, a missing symbol, a build that dies. Those are easy — the toolchain tells you. This failure mode is the dangerous one precisely because the toolchain is happy. The package’s whole job is to be a silent stand-in, and it does that job perfectly. There is no error to grep for, no red in the log, nothing in CI that goes amber.
For anything security-critical, this means the name on the dependency line is not evidence of anything. A package called sqlcipher_flutter_libs that installs without error tells you a package resolved. It does not tell you a single byte is encrypted. Those are completely different claims, and the gap between them is exactly where a finance app’s database sits unprotected while the team believes it’s locked down.
What we shipped instead, and how we checked
Instead of trusting the named package, we used package:sqlite3 3.x’s build hook to pull in sqlite3mc — the actual encryption-capable SQLite build.
Then we verified the mechanism rather than assuming it. The check was concrete: confirm that libsqlite3mc.so is actually present in the built APK, for all three Android ABIs. Not “the dependency resolved.” Not “the build passed.” The real native library, in the real artifact, for every architecture the app ships to. If encryption is going to be a claim we make, the proof has to live in the thing users install, not in the lockfile.
That is the transferable lesson, and it isn’t specific to Flutter or Dart. A dependency that installs and builds without error is not evidence it does what its name implies. Some packages are deprecated into silent no-ops. For anything where being wrong is a security incident, verify the actual mechanism in the shipped artifact — the .so, the binary, the behaviour — not that the pubspec or package.json line turned green.
The failure modes we chose on purpose
Encryption was only half of it. The app also needed a device-level lock — biometric or PIN before the ledger opens. Security UX has its own trap: the obvious “secure” choice is often the one that locks the legitimate owner out of their own data. We made three deliberate calls here.
- The key lives only in the platform keystore — Keychain on iOS, EncryptedSharedPreferences on Android — generated with
Random.secure(). It is never stored beside the database. It is also not rotated automatically, and that’s a conscious tradeoff: rotating the key means rewriting the entire database mid-launch, which is its own reliability risk. We’d rather document that than pretend we got rotation for free. - The app lock is biometric-OR-PIN, not biometric-only. A biometric-only lock sounds stronger until a fingerprint sensor breaks and a user is permanently locked out of their own financial records with no way back in. The PIN is the recourse.
- An auth failure or an absent lock setup does not block the app from opening. This is the deliberate direction of the failure. The system is designed so that a problem with the lock can’t become a wall between someone and their money.
That last point sits next to the encryption lesson in an interesting way. The two failures point in opposite directions and both are intentional. Encryption must fail safe — it must never silently downgrade to plaintext, which is exactly why the empty-package trap is so dangerous and why we verify the artifact. The app lock must fail open — it must never trap the legitimate owner outside their own data. “Fail secure” is not one setting you flip on everywhere. You decide, per mechanism, what the dangerous outcome is and make sure the system can’t drift into it quietly.
If you take one thing from this
When a dependency’s job is security, add one step to your definition of done: open the built artifact and confirm the mechanism is physically there. The gap between “the package resolved” and “the data is encrypted” doesn’t show up in any log — and on a finance app, that’s the gap that matters most.










