Implementing Flavor-Specific Biometric Security Policies
Introduction: The Security-UX Paradox in Fintech
In the Abuja fintech ecosystem, where transaction volume is high and the threat landscape is evolving, we rarely ship a 'one-size-fits-all' security model. Our production app requires rigorous authentication for every sensitive action, while our 'Sandbox' or 'UAT' flavors often demand a more permissive environment for rapid developer iteration.
When we talk about biometric security in Flutter, we often reach for the local_auth package. However, relying on a high-level abstraction without understanding the underlying platform security primitives is how vulnerabilities are introduced. As a contributor to flutter_secure_storage, I have seen firsthand how developers assume that just because a prompt appears, the data is encrypted. The reality is that the security of your app depends on how you configure the Secure Enclave (iOS) and the Android Keystore (Android) to interact with biometric state—a process that must change based on your deployment flavor.
The Threat Model: Beyond Simple Authentication
Before writing a single line of Dart, we need to distinguish between authentication and authorization. The local_auth package verifies that the user is the device owner. It does not, however, cryptographically bind that verification to the data being accessed.
If you simply check authenticate(), and then perform a network request, you are vulnerable to time-of-check to time-of-use (TOCTOU) attacks. To build truly secure fintech apps, we must implement Biometric Binding. This involves generating a cryptographic key inside the hardware-backed keystore that requires user authentication to unlock.
When we manage this across build flavors—dev, staging, and prod—the complexity shifts. For instance, prod requires BiometricStrength.strong with hardware-backed validation, while dev might fall back to BiometricStrength.weak or device_passcode to accommodate various test emulators that lack biometric hardware.
Step-by-Step: Architecting Flavor-Aware Security
To manage this, we define a Security Configuration Provider. This pattern decouples our UI logic from the platform-specific security requirements defined by our CI/CD flavor flags.
1. Define the Security Interface
First, we create an abstraction that handles our requirements. We use flutter_dotenv or compile-time variables (--dart-define) to feed configuration into our app.
abstract class SecurityPolicy {
final bool requireHardwareBacked;
final int authTimeoutSeconds;
SecurityPolicy({required this.requireHardwareBacked, required this.authTimeoutSeconds});
Future<bool> authenticate();
}
2. Implementing the Platform-Specific Handler
When building for Android, we must manipulate the KeyGenParameterSpec. The flutter_secure_storage package handles the writing of the data, but the binding logic requires native Kotlin code to ensure the key is invalidated if new biometrics are enrolled.
- Use the
BiometricPromptAPI for Android 10+ (API level 29+). - Set
setUserAuthenticationRequired(true)in theKeyGenParameterSpec.Builder. - Define
setUserAuthenticationValidityDurationSecondsbased on your flavor policy.
3. Configuring the Build Flavors
In your build.gradle or ios/Runner.xcodeproj, you must ensure that your AndroidManifest.xml or Info.plist reflects the security posture. For example, NSFaceIDUsageDescription should be descriptive in production, while a generic string suffices for dev.
Deep Dive: The Internals of Biometric Binding
Let’s look at the flutter_secure_storage internals. When we perform a write operation, we are essentially delegating to the native layer. If you are using Android, the internal Kotlin code calls KeyStore.getInstance("AndroidKeyStore"). If you have configured your biometric policy to be hardware-bound, the key material never leaves the Trusted Execution Environment (TEE).
If a user adds a new fingerprint to their device, a 'strong' policy should trigger a key invalidation. This is where most Flutter developers get it wrong. They rely on the local_auth package to 'check' for biometrics, but they don't actually rotate the underlying encryption key. If you use the same key after a biometric enrollment change, you are potentially exposing previously encrypted data to an unauthorized party.
Implementation Snippet for Key Invalidation
// Inside your MainActivity.kt (Android platform side)
val keyGenParameterSpec = KeyGenParameterSpec.Builder(
KEY_ALIAS,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setUserAuthenticationRequired(true)
.setInvalidatedByBiometricEnrollment(true) // Crucial for security
.build()
By setting setInvalidatedByBiometricEnrollment(true), you tell the Android OS that if the user adds a new fingerprint, the key is permanently destroyed. This is a "security-first" stance that prevents an attacker with physical device access from enrolling their own biometric and accessing your encrypted tokens.
Platform-Specific Gotchas and Troubleshooting
Navigating the differences between iOS (Keychain) and Android (Keystore) is the primary friction point for mobile engineers.
The Keychain Survival Problem:
On iOS, items in the Keychain persist even after the app is uninstalled. If you are developing a dev flavor, this can lead to 'ghost data' during testing. To mitigate this, our dev flavor initialization routine includes a 'first-run' check that clears the specific keychain namespace if a VERSION_CODE mismatch is detected. This prevents test data leakage into the next build cycle.
Android Keystore Versioning:
On older Android devices, hardware-backed Keystore support is inconsistent. Your flavor policy must account for this. We use a DeviceSecurityCapability helper class that checks for isHardwareBacked() during the application bootstrap phase. If the device fails the prod security requirements (e.g., lack of TEE), we force the app to exit or downgrade to a limited 'read-only' mode, depending on the business logic flavor.
Pro Tips for Secure Implementations:
- Use
flutter_secure_storagewithAndroidOptions.encryptedSharedPreferences: It provides a more robust layer than basicSharedPreferences, especially for storing small tokens and biometric flags. - Avoid storing raw data: Even with secure storage, treat the stored value as a 'gatekeeper' key. Use this key to decrypt larger data blobs stored in a separate, encrypted SQLite database using
sqflitewithSQLCipher. - Monitor the
local_authlogs: When debuggingdevbuilds, set theStickyAuthproperty totrueto maintain authentication states across background/foreground lifecycle events, but never enable this inprod. - Audit the
SecurityException: Always wrap your biometric calls in a try-catch block specifically forPlatformException. On many Android devices, the biometric sensor might be temporarily locked out after too many failures. Your UX must handleLockedOutandPermanentLockoutstates gracefully.
Conclusion: The Path Forward
Security is not a feature; it is an architectural commitment. By tying your Flutter biometric policy to your build flavors, you create a system that is as flexible as it is robust. You enable rapid prototyping in dev while maintaining the 'hardened' security standard required for prod financial transactions.
Remember, your users are not just trusting your code; they are trusting the TEE/Secure Enclave. By setting the correct flags—setInvalidatedByBiometricEnrollment on Android and kSecAccessControlUserPresence on iOS—you ensure that your app's biometric binding is not just a UI prompt, but a cryptographic barrier against unauthorized access. As we continue to push the boundaries of what is possible in Flutter, our responsibility remains to keep these underlying platform primitives accessible but correctly configured. Never treat biometric auth as a simple boolean check; treat it as an extension of your key management lifecycle.