biometric_security 0.1.14
biometric_security: ^0.1.14 copied to clipboard
Biometric security for Flutter: Face ID, Touch ID & fingerprint authentication with hardware-backed encrypted storage and app-lock, on Android and iOS.
biometric_security #
Biometric authentication and secure, hardware-backed encrypted storage for
Flutter — Face ID, Touch ID, and fingerprint (Android
BiometricPrompt, iOS LocalAuthentication), with biometric-gated secret
storage backed by the Android Keystore and iOS Keychain / Secure
Enclave, plus app-lock, key rotation/revocation, and honest availability
detection for Android and iOS. A safer, higher-level alternative to
wiring local_auth and flutter_secure_storage together by hand.
Status: 0.1.x — public beta. The cryptographic design is sound and unit-tested, an independent security review fixed all high-risk findings, and every implemented flow has been validated on physical Android and iOS devices. What's left is what can't be exercised on a standard device — a few still-stubbed APIs and desktop-platform support (see Platform limitations). As a pre-1.0 release, the public API may still change.
Table of contents #
- Package overview · 2. Why this package exists · 3. Key features · 4. Installation · 5. Quick start · 6. Authentication · 7. Availability detection · 8. Biometric types · 9. Biometric policies · 10. Secure storage · 11. Biometric-protected storage · 12. App lock · 13. Feature-level protection · 14. Enable/disable protection · 15. Enrollment changes · 16. Key invalidation · 17. Revocation · 18. Recovery · 19. Security architecture · 20. Threat model · 21. Android details · 22. iOS details · 23. Platform limitations · 24. Error handling · 25. Testing · 26. FAQ · 27. Security recommendations · 28. License
1. Package overview #
biometric_security gives Flutter apps one small API for the whole biometric
security chain: detect what the device can do, authenticate the user against a
hardware key (not a forgeable boolean), and store secrets encrypted with
AES-256-GCM under a key that is physically unusable without a successful
biometric check. It normalizes the very different Android and iOS behaviors —
strength classes, enrollment-change invalidation, device-credential fallback —
into one SecurityPolicy.
2. Why this package exists #
The common Flutter pattern — local_auth returns true, so unlock the data — is
not a security boundary. On a rooted or hooked device that boolean is
trivially forged. The real boundary is a key in the Android Keystore / iOS
Secure Enclave whose use requires biometric authentication. Assembling that
correctly (enrollment invalidation, strong-vs-weak biometrics, biometryCurrentSet
vs biometryAny, key rotation, safe failure) is where bugs and data loss live.
This package makes the cryptographic binding the default, and fails loudly
instead of silently returning plaintext.
How it compares #
biometric_security combines and hardens what you'd otherwise wire up from
local_auth + flutter_secure_storage:
| Capability | local_auth |
flutter_secure_storage |
biometric_security |
|---|---|---|---|
| Show the biometric prompt (Face ID / Touch ID / fingerprint) | ✅ | — | ✅ |
Result is a real hardware key operation, not just true |
❌ boolean | — | ✅ |
| Encrypted secret storage (AES-256-GCM) | — | ✅ | ✅ |
| Read is gated by biometrics (not only encrypted at rest) | — | ⚠️ coarse | ✅ |
| Enrollment-change invalidation surfaced to your app | ❌ | ⚠️ opaque | ✅ typed exception |
| Key rotation and revocation | — | — | ✅ |
| App-lock & feature-level gating | — | — | ✅ |
One normalized SecurityPolicy across Android & iOS |
— | — | ✅ |
3. Key features #
- ✅ Biometric availability, strength, and enrolled-modality detection.
- ✅ Hardware-backed authentication (
BiometricPrompt+CryptoObject/ Secure Enclave signing). - ✅ AES-256-GCM envelope encrypted storage; per-secret data-encryption keys.
- ✅ Biometric-gated reads; encryption-at-rest for non-sensitive data.
- ✅ One normalized, secure-by-default
SecurityPolicy. - ✅ App-lock and feature-level gating.
- ✅ Key rotation, revocation, enrollment-change invalidation, migration.
- ✅ Typed error model — never returns plaintext on failure.
4. Installation #
dependencies:
biometric_security: ^0.1.0
Android — set minSdkVersion 24 (or higher) and make your activity a
FlutterFragmentActivity (required by BiometricPrompt):
// android/app/src/main/kotlin/.../MainActivity.kt
import io.flutter.embedding.android.FlutterFragmentActivity
class MainActivity : FlutterFragmentActivity()
iOS — set the deployment target to 13.0+ and add a Face ID usage string (the app crashes without it):
<!-- ios/Runner/Info.plist -->
<key>NSFaceIDUsageDescription</key>
<string>Authenticate to protect and unlock your secured data.</string>
5. Quick start #
import 'package:biometric_security/biometric_security.dart';
final security = BiometricSecurity();
Future<void> main() async {
await security.initialize();
final availability = await security.getAvailability();
if (!availability.canAuthenticate) {
// Fall back to password, or ask the user to enrol a biometric.
return;
}
// Store a secret behind a strong biometric gate.
await security.write(
key: const SecretKey('payment_pin'),
value: '4321',
policy: SecurityPolicy.strong(),
reason: 'Confirm to secure your PIN',
);
// Reading prompts the user (a real key operation runs).
final pin = await security.read(
key: const SecretKey('payment_pin'),
reason: 'Unlock your PIN',
);
print(pin); // "4321"
}
6. Authentication #
authenticate() runs a real hardware key operation and returns a verified
AuthSession on success, or throws a typed exception.
try {
final session = await security.authenticate(
reason: 'Verify it is you',
);
print('Authenticated via ${session.securityLevel}');
} on BiometricAuthCanceledException {
// User backed out — normal control flow.
} on BiometricLockedOutException catch (e) {
showBanner(e.isPermanent ? 'Unlock with your device PIN' : 'Try again later');
} on BiometricSecurityException catch (e) {
showBanner(e.message);
}
For server-verifiable authentication (a signed challenge),
signChallenge()is planned but not yet implemented in 0.1.0.authenticate()is a local gate; checksession.securityLevelbefore trusting it for high-value flows.
7. Availability detection #
final a = await security.getAvailability();
print(a.isSupported); // device has biometric hardware
print(a.supportedModalities); // {fingerprint, face, ...} — hardware present
print(a.enrolledModalities); // what the user actually set up (empty on Android — see below)
print(a.strength); // strong | weak | none
print(a.canAuthenticate); // usable right now?
print(a.status); // ready | notEnrolled | lockedOut | ...
print(a.hasStrongBox); // Android StrongBox
print(a.hasSecureEnclave); // iOS Secure Enclave
8. Biometric types #
The API keeps five distinct concepts separate — conflating them is the #1 biometric bug:
| Concept | Where | Reliable? |
|---|---|---|
| Supported (hardware present) | supportedModalities |
Yes |
| Enrolled (user set up) | enrolledModalities |
Yes (Android can't enumerate — always empty) |
| Available (usable now) | canAuthenticate / status |
Yes |
| Requested preference | SecurityPolicy.preferredModality |
Advisory only |
| Guaranteed modality | does not exist | Impossible on both OSes |
You cannot force Face ID or fingerprint — the OS decides.
EnforceableGuarantees.canForceSpecificModality is always false.
9. Biometric policies #
One SecurityPolicy expresses intent; the plugin maps it to each platform.
The default is the strongest sensible configuration.
SecurityPolicy.strong(); // strong biometric, per-use, invalidate on enrollment change (default)
SecurityPolicy.balanced(); // survives new enrollments, short reuse window
SecurityPolicy.convenient(); // allows device PIN/passcode fallback (weaker)
SecurityPolicy.encryptedOnly(); // encryption at rest, no prompt
// Or fully custom:
const SecurityPolicy(
minimumStrength: BiometricStrength.strong,
deviceCredentialFallback: DeviceCredentialFallback.disallow,
enrollmentBinding: EnrollmentBinding.invalidateOnChange,
authValidity: AuthValidity.perOperation,
hardwareRequirement: HardwareRequirement.requireSecureHardware,
accessibility: StorageAccessibility.whenUnlockedThisDeviceOnly,
);
10. Secure storage #
Non-sensitive data — encrypted at rest, no prompt:
await security.write(
key: const SecretKey('api_token'),
value: token,
policy: SecurityPolicy.encryptedOnly(),
);
final token = await security.read(key: const SecretKey('api_token'));
11. Biometric-protected storage #
Sensitive data — the read prompts and is backed by a hardware-gated key:
const pin = SecretKey('payment_pin');
await security.write(
key: pin,
value: '4321',
policy: SecurityPolicy.strong(),
reason: 'Secure your PIN',
);
final value = await security.read(key: pin, reason: 'Unlock your PIN');
12. App lock #
// Setup:
await security.appLock.enable(
policy: SecurityPolicy.balanced(),
reason: 'Enable app lock',
);
// On resume:
if (await security.appLock.isEnabled()) {
try {
await security.appLock.unlock(reason: 'Unlock MyApp');
goHome();
} on BiometricAuthCanceledException {
stayLocked();
}
}
13. Feature-level protection #
await security.features.setPolicy(
featureId: 'export_keys',
policy: SecurityPolicy.strong(),
);
Future<void> onExport() async {
await security.features.guard(
featureId: 'export_keys',
reason: 'Authenticate to export',
);
performExport();
}
14. Enable/disable protection #
await security.enableProtection(
key: const SecretKey('note'),
policy: SecurityPolicy.strong(),
);
await security.disableProtection(key: const SecretKey('note'));
enableProtection/disableProtectionare declared but not yet implemented in 0.1.0. To change protection today,readthenwritewith the new policy.
15. Enrollment changes #
When the user adds/removes a fingerprint or face, a SecurityPolicy with
EnrollmentBinding.invalidateOnChange (the default) makes protected data
inaccessible — defending against a coerced new enrollment.
security.lifecycleEvents.listen((event) {
if (event.type == KeyLifecycleEventType.keyInvalidated) {
// Prompt the user to re-provision (see Recovery).
}
});
Lifecycle-event emission is not wired in 0.1.0; today you learn of invalidation from a
KeyInvalidatedExceptionon the nextread.
Testing biometric enrollment changes #
Enrollment-change behavior is native and can only be verified on a physical device — emulators/simulators cannot enroll real biometrics. The example app has a Biometric Enrollment section that drives this test.
What the package actually does (default SecurityPolicy.strong()): the key
protecting a secret is bound to the enrolled biometric set. When that set
changes, the key is invalidated and the next read() throws
KeyInvalidatedException — the package does not silently return stale data.
Three distinct things collapse into that one observable:
- The biometric set changed — the OS-level cause.
- The cryptographic key was invalidated — the platform consequence.
- The protected data can no longer be decrypted — what your app observes.
You detect all three by attempting read() and catching KeyInvalidatedException.
There is intentionally no separate "has the set changed?" probe — the key state
is the source of truth.
Android (setInvalidatedByBiometricEnrollment(true), default):
- Store, then read the protected PIN (succeeds).
- Settings → Security → add a new fingerprint/face.
- Return to the app → Read Protected PIN →
KeyInvalidatedException. Detection happens at cipher init, before any prompt is shown.Note: Android invalidates on new enrollment; behavior on removal varies by OEM.
iOS (.biometryCurrentSet, default):
- Store, then read the protected PIN (succeeds).
- Settings → Face ID & Passcode → add or remove a face/fingerprint.
- Return to the app → Read Protected PIN →
KeyInvalidatedException. iOS detects the change via the biometric domain-state, without a prompt.
Expected result: "Secure key valid" → INVALIDATED, "Protected PIN
accessible" → false.
Limitation: a policy with EnrollmentBinding.persistAcrossEnrollment
(Android setInvalidatedByBiometricEnrollment(false) / iOS biometryAny)
survives new enrollments by design — do not expect invalidation there.
Recovery: call resetInvalidated() to clear the dead key, then re-write()
the secret from your source of truth (the server). The old ciphertext is
unrecoverable by design.
16. Key invalidation #
If the protecting key is invalidated (enrollment change or the device lock being disabled), reads fail loudly — the package never returns plaintext and never silently regenerates the key:
try {
await security.read(key: const SecretKey('payment_pin'), reason: 'Unlock');
} on KeyInvalidatedException {
await recover(); // see below
}
17. Revocation #
await security.revoke(key: const SecretKey('payment_pin')); // one secret, unrecoverable
await security.revokeAll(); // hard kill-switch (e.g. logout)
delete removes data; revoke removes data and destroys the key so the
secret can never be decrypted again.
18. Recovery #
Hardware keys are device-bound and non-transferable, so after invalidation or a device change, re-provision from your source of truth:
Future<void> recover() async {
await security.resetInvalidated(); // clear the dead key
final fresh = await api.reissueToken(); // server is the source of truth
await security.write(
key: const SecretKey('api_token'),
value: fresh,
policy: SecurityPolicy.strong(),
reason: 'Re-secure your account',
);
}
19. Security architecture #
- Envelope encryption. Each secret gets a random 256-bit DEK; the payload is sealed with AES-256-GCM (96-bit random nonce, 128-bit tag). The DEK is held by the hardware key vault (Android Keystore / iOS Keychain), biometric- gated per policy.
- The gate is a key, not a boolean. Gated operations run a real key op; a forged success can't unlock data.
- Hardware keys never cross into Dart. Only ciphertext, the DEK (transiently), and metadata cross the platform channel.
- Fail loud. Corruption →
SecureStorageException; bad tag →CryptographicException; invalidation →KeyInvalidatedException. Never plaintext.
20. Threat model #
Protects against: offline attackers with the device or a backup (secrets are
hardware-encrypted and device-bound), forged-boolean bypass, coerced new
enrollment (with invalidateOnChange), ciphertext tampering, IV reuse.
Does not protect against: a fully compromised OS/kernel or hardware attack; extraction from a rooted/jailbroken device's live process memory; forcing a specific modality; making hardware-bound secrets survive device migration. Integrity (root/jailbreak) signals are advisory, never guarantees.
21. Android details #
- Android Keystore holds every key (TEE- or StrongBox-backed); non-exportable.
- BiometricPrompt with
CryptoObject; gated ops run inside the success callback. - KeyGenParameterSpec: AES-256-GCM,
setUserAuthenticationRequired,setUserAuthenticationParameters(0, AUTH_BIOMETRIC_STRONG[|AUTH_DEVICE_CREDENTIAL]),setInvalidatedByBiometricEnrollment, optional StrongBox with TEE fallback. - Strength: only
BIOMETRIC_STRONG(Class 3) can gate keys. - Requires
FlutterFragmentActivity;minSdk 24.
22. iOS details #
- Keychain items with
SecAccessControl;ThisDeviceOnly(excluded from backups). biometryCurrentSet(default) vsbiometryAnyfor enrollment binding.- Secure Enclave P-256 signing backs
authenticate(). - LocalAuthentication for availability;
biometryTypeis UX-only. - Requires
NSFaceIDUsageDescription; iOS 13.0+.
23. Platform limitations #
- No forced modality on either OS (Face ID vs fingerprint).
- Android can't enumerate enrolled modalities — use
status/strength. - Hardware keys are device-bound — no migration/restore; re-provision.
- iOS Keychain survives app reinstall; Android Keystore is wiped — behavior differs; a first-run purge for iOS is planned.
- Some APIs are still stubs:
signChallenge, lifecycle-event emission,enableProtection/disableProtection, andpolicyOfare not implemented in 0.1.0. - macOS/Windows/Linux are not yet supported.
- Root/jailbreak behavior can't be exercised on a standard device — integrity signals are advisory only, never a guarantee.
Everything else — availability/strength detection, biometric authentication, gated storage, enrollment-change invalidation, rotation, and revocation — has been verified on physical Android and iOS devices.
24. Error handling #
All failures are subtypes of the sealed BiometricSecurityException:
| Exception | Meaning |
|---|---|
BiometricAuthCanceledException |
user dismissed the prompt |
BiometricAuthFailedException |
biometric did not match |
BiometricLockedOutException |
too many attempts (isPermanent) |
BiometricUnavailableException |
no hardware / temporarily unavailable |
BiometricNotEnrolledException |
nothing enrolled |
KeyInvalidatedException |
key invalidated by enrollment/lock change |
CryptographicException |
tampered/corrupt ciphertext (no plaintext returned) |
SecureStorageException |
storage/metadata failure |
PolicyUnsupportedException |
device can't satisfy the policy |
UnsupportedPlatformException |
platform not implemented |
25. Testing #
flutter analyze
flutter test # Dart unit tests (storage engine, policy, facade)
cd example && flutter build apk --debug # Android
cd example && flutter build ios --no-codesign # iOS
Native unit tests: cd example/android && ./gradlew :biometric_security:testDebugUnitTest
and the Xcode RunnerTests scheme.
26. FAQ #
Can I force Face ID / fingerprint? No — neither OS allows it. You can require strength (Android) and present appropriate UI.
Do I have to await initialize()? Yes, once, before anything else.
Is my secret gone after an OS/biometric change? If protected with the default
invalidateOnChange policy, yes — by design. Re-provision from your backend.
Does it work on emulators/simulators? Availability and non-gated storage do. Real biometric prompts and Secure Enclave need physical devices.
27. Security recommendations #
- Keep the default
SecurityPolicy.strong()for anything sensitive. - Never treat
authenticate()'s result as server proof; use a signed challenge (when available) plus server-side authorization. - Store only what you must on-device; keep the server as the source of truth for recoverable data.
- Set
android:allowBackup="false"(or exclude thebsec.*prefs) for defense in depth. - Test enrollment-change and reinstall flows on real devices before shipping.
- Review the platform limitations above and handle each one for your use case.
28. License #
BSD 3-Clause — see LICENSE. Contributions welcome; see
CONTRIBUTING.md. Report vulnerabilities per
SECURITY.md.