How do you make a Flutter app require biometrics before flutter_secure_storage releases a secret on Android and iOS, and what can go wrong?
answer
- gate the key, not the screen
- AndroidOptions.biometric(enforceBiometrics: true)
- AndroidBiometricType.strongBiometricOnly
- AccessControlFlag.biometryCurrentSet
- cancel, no enrolment, API levels
basics
~10 sUse AndroidOptions.biometric(enforceBiometrics: true) on Android and IOSOptions(accessControlFlags: [...]) such as biometryCurrentSet or userPresence on iOS, so the OS demands authentication before the key or item is used, not just before a screen opens.
solid answer
~40 sThe point is to bind the secret to authentication, so a bypassed UI check does not expose it. On Android, `AndroidOptions.biometric()` creates the data key in the Keystore with AES-GCM ciphers; on a device with a screen lock that key requires user authentication and is invalidated when biometrics are re-enrolled. `enforceBiometrics` decides what happens on a device without a screen lock: `true` throws, while the default `false` silently stores the secret without authentication. `biometricType: AndroidBiometricType.strongBiometricOnly` rejects device credentials, fully enforced from API 30, and shows a dismiss button labelled by `biometricPromptNegativeButton`. The app must declare `USE_BIOMETRIC`. On iOS, `accessControlFlags` such as `userPresence` (biometrics or passcode) or `biometryCurrentSet` (invalidated when enrolment changes) gate the Keychain item. Handle cancellation as a `PlatformException`, and plan for items becoming unreadable after re-enrolment.
code
dart · 23 linesimport 'package:flutter/services.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
const vault = FlutterSecureStorage(
aOptions: AndroidOptions.biometric(
enforceBiometrics: true,
biometricType: AndroidBiometricType.strongBiometricOnly,
biometricPromptTitle: 'Unlock wallet',
biometricPromptNegativeButton: 'Cancel',
storageNamespace: 'wallet_vault',
),
iOptions: IOSOptions(
accessControlFlags: [AccessControlFlag.biometryCurrentSet],
),
);
Future<String?> readSigningSecret() async {
try {
return await vault.read(key: 'signing_secret');
} on PlatformException {
return null; // cancelled, locked out or invalidated: offer retry
}
}go deeper
Recall that flutter_secure_storage can require biometrics on Android and iOS, and that a cancelled prompt throws.
Explain AndroidOptions.biometric parameters and the iOS AccessControlFlag values, including the enforceBiometrics default.
Argue key-bound over UI-only checks, and handle cancellation, re-enrolment, insecure devices and background reads in the design.
Decide which secrets deserve per-use authentication, trading friction and recovery flows against the damage a stolen device could do.
## Gate the secret, not the screen A crypto-wallet app might ask for a fingerprint before showing the recovery options or before using the refresh token to sign a transaction. There are two ways to do that: - A **UI-only check**: show a biometric prompt, and if it succeeds, read the secret normally. The secret itself is unprotected; code that skips the prompt, or a debugger, still reads it. - A **key-bound check**: store the secret so the operating system refuses to use the key, or return the item, until the user authenticates. Reading the value triggers the system prompt. **flutter_secure_storage** supports the second approach on both mobile platforms, and interviewers look for a candidate who knows why it is stronger. ## Android: AndroidOptions.biometric() `AndroidOptions.biometric()` switches both ciphers to `AES_GCM_NoPadding`, so the data key is created inside the **Android Keystore** rather than wrapped by an RSA key. When the device has a PIN, pattern, password or biometric, the plugin generates that key with user authentication required, invalidated by new biometric enrolment, and, from API 28, usable only while the device is unlocked. Its main parameters: | Parameter | Default | Effect | |---|---|---| | `enforceBiometrics` | `false` | only matters on a device without a screen lock: `true` throws, `false` stores the secret without authentication | | `biometricType` | `biometricOrDeviceCredential` | `strongBiometricOnly` accepts only Class 3 biometrics | | `biometricPromptTitle` / `biometricPromptSubtitle` | `Authenticate to access` / `Use biometrics or device credentials` | prompt text | | `biometricPromptNegativeButton` | `Cancel` | dismiss label the system needs for `strongBiometricOnly` and on Android 10 and lower | Platform limits worth stating precisely: - The constructor documents API 28 (Android 9) as the minimum for enforced biometric authentication. - `strongBiometricOnly` is fully enforced from API 30 (Android 11); on older versions the system may still allow device credentials. - The app must add `android.permission.USE_BIOMETRIC` to its manifest. The default `enforceBiometrics: false` is a common trap: on a phone without a screen lock, the wallet silently stores the secret without any authentication requirement, and nothing tells the app unless it checks. ## iOS: access control flags on the Keychain item On iOS and macOS, `IOSOptions(accessControlFlags: [...])` attaches an access-control policy to the Keychain item. Relevant `AccessControlFlag` values: 1. `userPresence`: Face ID, Touch ID or the device passcode. 2. `biometryAny`: any enrolled biometric, surviving enrolment changes. 3. `biometryCurrentSet`: only the biometrics enrolled when the item was written; adding or removing a fingerprint or face makes the item unreadable. 4. `devicePasscode`: the passcode alone. 5. `or` and `and`: combine constraints, with one operator per combination, placed after the constraints. `useSecureEnclave: true` goes further: each value is encrypted with a per-item AES key wrapped by a Secure Enclave key, gated by these flags (defaulting to `userPresence`), and falls back to a normal Keychain item on hardware without an enclave. Items written without it are not re-encrypted; reading such a key with `useSecureEnclave: true` returns `null`, so adoption needs an explicit read, rewrite and delete. ## Keeping two stores apart Most wallets need both kinds of secret: a refresh token that background work reads silently and a signing secret that must never be used without the user present. Putting both in one gated store breaks background refresh; putting both in an ungated store weakens the signing secret. The clean design is two `FlutterSecureStorage` instances: - A standard store with default `AndroidOptions()` and a `first_unlock_this_device` accessibility on iOS for the refresh token. - A biometric store with `AndroidOptions.biometric(enforceBiometrics: true, storageNamespace: 'wallet_vault')` and `accessControlFlags` on iOS for the signing secret. The namespace keeps the two Android configurations from sharing Keystore aliases or wrapped-key storage, which the package documents as the reason `storageNamespace` exists. ## What goes wrong in production - **Cancellation and lockout** surface as a `PlatformException` from `read`; the UI must offer retry or a fallback path, not treat it as a missing token. - **Re-enrolment** invalidates `biometryCurrentSet` items on iOS and the biometric Keystore key on Android (where the default `resetOnError` then deletes the unreadable data), so the wallet must be able to re-issue the secret after the user signs in again. - **Devices without security**: with `enforceBiometrics: true`, writes throw, so check the device state and explain the requirement. - **Background reads**: an item gated by authentication cannot be read silently from a background task, so keep the background-refresh token in a separate, ungated item. - **Mixed configurations**: use `storageNamespace` when one app needs both a standard and a biometric store on Android.
- Why is a biometric prompt shown before an ordinary flutter_secure_storage read weaker than AndroidOptions.biometric(enforceBiometrics: true)?The prompt only guards the UI path; the stored secret itself needs no authentication, so any code path that skips the prompt can read it. With `AndroidOptions.biometric()` on a secured device, the Keystore refuses to use the key until the user authenticates, so the protection travels with the data.
- On iOS with flutter_secure_storage, when would you choose biometryAny over biometryCurrentSet?When availability matters more than detecting a newly added fingerprint or face. `biometryCurrentSet` makes the item unreadable after any enrolment change, which protects against someone adding their own biometric but forces a re-setup; `biometryAny` keeps working across enrolment changes.
saying these in an interview costs you the question
- With enforceBiometrics: false, Android reads never ask for authentication.
- A local biometric prompt before a normal read protects the stored secret.
- biometryCurrentSet items stay readable after the user adds a fingerprint.
- strongBiometricOnly is fully enforced on every Android version.
- Biometric-gated items can be read silently by background refresh.