A package with a green “null safety” badge, 2,000+ likes, and a changelog that stops eight months before your Flutter SDK’s current stable release is not a safe default. It’s a liability with good marketing. We found this out the hard way while integrating a biometric-auth plugin for a client’s Flutter app, and the fifteen minutes we budgeted for “add dependency, wire up the API” turned into a day and a half of reading Kotlin and Swift source, then forking the package outright. This is what we found, and what we changed.
The setup: why we even opened the native code
The plugin in question wrapped BiometricPrompt on Android and LAContext on iOS behind a single Dart API. On paper it was exactly what we wanted: don’t hand-roll platform channels for something as security-sensitive as biometric auth when a maintained wrapper exists. Rung five of any sane dependency ladder — use the existing package instead of writing the native code yourself.
We open native code on any dependency that touches three things: auth, payment, or anything crossing a platform channel with app-controlled input feeding into it. Biometric auth is two of the three. So before wiring it into the client’s login flow, we read the Android and iOS implementation, not just the Dart-facing API.
Red flag one: reflection to dodge a Gradle version bump
The Android side needed access to a FragmentActivity method that had changed signature across AndroidX releases. Instead of declaring a compileOnly dependency pinned to the AndroidX version the plugin actually supported, the author used Java reflection to look up the method at runtime and invoke it dynamically, wrapped in a try/catch that swallowed NoSuchMethodException and silently fell back to a no-op.
The failure mode: on any AndroidX version the author hadn’t tested against, biometric prompt silently does nothing. Not “throws an error the app can catch” — nothing. The Dart-side Future just never resolves, or resolves with a default false that reads identically to “user declined authentication.” In a login flow, that’s the difference between “auth failed” and “auth was silently skipped,” and from the call site those are indistinguishable. A security control that fails open under a dependency-version mismatch, with no logging, is worse than not having the reflection shortcut at all — the honest failure is a compile error, not a runtime no-op.
Reflection to work around an API surface you don’t want to version-pin is a shortcut that trades a build-time error for a runtime silent failure. That trade is never worth it in an auth path.
Red flag two: unchecked platform channel results
The second issue was smaller in diff size but worse in blast radius. The MethodChannel handler on the iOS side returned a nullable NSDictionary from the biometric evaluation callback, and the Dart side did:
final result = await _channel.invokeMethod('authenticate');
return result['success'] as bool;
No null check on `result`, no check that the map actually contained a `success` key. LAContext’s evaluatePolicy callback can return with an error and a nil result under conditions the plugin author evidently hadn’t hit in testing: no biometric hardware enrolled, an interrupted evaluation (app backgrounded mid-prompt), or a passcode-not-set device. Any of those produced a null platform result, which meant a null-check cast exception surfacing as an uncaught PlatformException on the Dart side — no domain error, no “biometrics unavailable” state the app could branch on, just a crash.
This is the platform-channel equivalent of not checking a return value from a syscall. Platform channels cross a serialization boundary; anything on the other side of that boundary is untrusted input as far as your Dart code is concerned, full stop, regardless of the fact that “you” wrote the native code too. Treating your own native implementation as trusted because you own both sides is exactly the assumption that breaks down when someone forks a two-year-old plugin and doesn’t reread it end to end.
What we forked, not what we rewrote
We didn’t rewrite the plugin. Forking a working, largely-correct package and patching the two failure modes is a much smaller diff than reimplementing platform-channel biometric auth from scratch, and it keeps the parts of the plugin that were fine — the actual BiometricPrompt/LAContext usage was solid. Our fork:
- Replaced the reflective method lookup with a compileOnly dependency on a specific AndroidX Fragment version, and a real compile-time error if the host app’s Gradle config doesn’t satisfy it — surfacing the incompatibility at build time instead of at 2am in production logs.
- Added a sealed result type on the Dart side (`BiometricSuccess`, `BiometricFailure(reason)`, `BiometricUnavailable`) instead of a raw bool, forcing every call site to handle the null/error cases explicitly rather than letting a bad cast propagate as an uncaught exception.
- Added the missing null and key-existence checks on the native side before anything crosses the channel, so a malformed platform result becomes a typed `BiometricFailure` instead of a crash.
The whole diff was under 200 lines against the upstream package. That’s the point — a dependency audit isn’t a rewrite-or-trust binary choice. Most of the time the right output is a small, well-understood patch you can explain line by line, sitting on top of code you didn’t have to write yourself.
Closing
This is the kind of read we do on any dependency that sits between app logic and something security-sensitive — auth, payments, anything crossing a platform channel — before it ships in a client’s app, not after an incident report. It’s slower than `flutter pub add` and trusting the like count, and that’s exactly why we build it into how we work on Flutter projects, mobile or otherwise.