biometric_security 0.1.14 copy "biometric_security: ^0.1.14" to clipboard
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 #

pub package pub points likes platform license: BSD-3-Clause

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 #

  1. 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; check session.securityLevel before 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 / disableProtection are declared but not yet implemented in 0.1.0. To change protection today, read then write with 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 KeyInvalidatedException on the next read.

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:

  1. The biometric set changed — the OS-level cause.
  2. The cryptographic key was invalidated — the platform consequence.
  3. 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):

  1. Store, then read the protected PIN (succeeds).
  2. Settings → Security → add a new fingerprint/face.
  3. Return to the app → Read Protected PINKeyInvalidatedException. 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):

  1. Store, then read the protected PIN (succeeds).
  2. Settings → Face ID & Passcode → add or remove a face/fingerprint.
  3. Return to the app → Read Protected PINKeyInvalidatedException. 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) vs biometryAny for enrollment binding.
  • Secure Enclave P-256 signing backs authenticate().
  • LocalAuthentication for availability; biometryType is 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, and policyOf are 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 the bsec.* 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.

2
likes
150
points
96
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Biometric security for Flutter: Face ID, Touch ID & fingerprint authentication with hardware-backed encrypted storage and app-lock, on Android and iOS.

Repository (GitHub)
View/report issues
Contributing

Topics

#biometrics #security #authentication #encryption #keystore

License

BSD-3-Clause (license)

Dependencies

cryptography, flutter, plugin_platform_interface

More

Packages that depend on biometric_security

Packages that implement biometric_security