Biometric session unlock: how Flutter, Android and iOS protect refresh tokens

Biometrics unlock a device-protected refresh token; the backend still validates the session. Android, iOS and Flutter settings, checked October 2026.

Read time
28 min
Word count
4.8K
Sections
15
FAQs
8
Share
Phone on a desk showing an Unlock with Face ID / Touch ID screen, beside a laptop with Flutter, Android and Apple logos
On this page · 15 sections
  1. The short answer: biometrics unlock a credential, the server validates the session
  2. Four pieces that are easy to confuse
  3. What happens when the user unlocks the app
  4. Android: BiometricPrompt and Android Keystore
  5. iOS: Local Authentication, Keychain and the Secure Enclave
  6. Flutter: which package does what
  7. Recommended Flutter configuration
  8. Managing the refresh-token lifecycle in Flutter
  9. A stronger option: bind the refresh token to a device key
  10. Web, desktop and other cross-platform stacks
  11. Eight common implementation mistakes
  12. Production checklist
  13. Final recommendation
  14. How eCorpIT can help
  15. References

Summary. Face ID, Touch ID or a fingerprint can reopen a signed-in mobile session without a password, but the biometric never turns into a token. The secure pattern keeps the refresh token, or the key that decrypts it, in storage the operating system releases only after the user authenticates: an Android Keystore key bound to BiometricPrompt, or an iOS Keychain item with an access-control policy. The app then sends the refresh token to the backend, which alone decides whether the session is still valid and issues a new access token. In Flutter, flutter_secure_storage 11.2.0 can apply those native protections, while local_auth 3.0.2 only shows a prompt and should not be the only thing guarding a token. Because mobile apps are OAuth public clients, RFC 9700 also requires the server to rotate refresh tokens or bind them to the device.

About this article. This guide is for mobile developers and architects adding biometric unlock to a Flutter app that uses token-based sign-in. Platform and package facts were checked on 31 October 2026 against Android Developers, Apple Developer, pub.dev package pages and package source code, IETF RFCs, the W3C WebAuthn specification and the OWASP Mobile Application Security project. Package APIs change between releases, so confirm versions against each changelog before adopting the snippets. Researched with AI assistance and reviewed by our editorial team.

The short answer: biometrics unlock a credential, the server validates the session

A mobile app can let an already signed-in user reopen a session with Face ID, Touch ID or a fingerprint instead of typing a password every time. The secure way to do it is not to convert a refresh token into an access token with a fingerprint. It is to protect the refresh token, or a cryptographic key that unlocks it, with the operating system's authentication-controlled storage. The usual flow is:

  1. The user signs in with the app's normal method, ideally an OAuth authorisation-code flow with PKCE in the system browser.
  1. The backend returns a short-lived access token and a longer-lived refresh token.
  1. Once the user opts in, the app stores the refresh token in Keystore- or Keychain-backed storage with a biometric or device-credential access policy.
  1. On a later launch, the operating system asks the user to authenticate before it releases the protected item or lets the key decrypt it.
  1. The app sends the refresh token to the token endpoint over HTTPS.
  1. The server checks expiry, revocation, rotation or device binding and other session rules, then returns a new access token and, if it rotates tokens, a new refresh token.

A successful biometric check does not prove that the refresh token is unexpired, does not contact the backend and does not extend the token's lifetime. The backend stays authoritative. This is one layer of the wider controls covered in our guide to mobile application security.

Four pieces that are easy to confuse

These concepts work together, but they are not interchangeable.

Concept What it does Who controls it
Biometric authentication Asks the device to verify an enrolled face or fingerprint The operating system and the device's secure hardware
Secure storage Holds a secret or key so that ordinary app storage access cannot read it Android Keystore or the iOS Keychain, optionally with the Secure Enclave
Access token Authorises API calls for a short period and a defined scope The backend's authorisation server
Refresh token Lets the app request a replacement access token, subject to server policy The backend's authorisation server

The app never receives a fingerprint image, a Face ID template or any reusable biometric secret. On Apple platforms, the Local Authentication framework coordinates with the Secure Enclave and hands the app only a success or failure result. On Android, Keystore key material never enters the app process, and the key's authentication rules are enforced outside it.

Three timers that should stay independent

  • Access-token lifetime: for example, 10 to 20 minutes. This is how long a token presented to APIs stays valid.
  • Refresh-token lifetime: set by the server, as an absolute limit, an inactivity limit or both. RFC 9700 says refresh tokens should expire after a period of client inactivity and may be revoked on events such as a password change or logout.
  • Local app-lock timeout: for example, ask for biometrics whenever the app returns from the background, or after five minutes of inactivity. This is a local experience and security policy.

These values do not synchronise themselves. A refresh token might stay valid for 30 days while the app asks for Face ID on every reopen. Equally, a fingerprint can succeed after the refresh token has expired, in which case the backend must reject the refresh and the app must ask for a full sign-in.

What happens when the user unlocks the app

A typical biometric-gated refresh runs in eight steps.

Step Between What happens
1 User and app The user opens the app or taps Unlock
2 App and secure storage The app asks Keystore or the Keychain for the protected refresh token
3 Operating system and user The system shows the Face ID, Touch ID, fingerprint or device-credential prompt
4 Operating system and app After a successful check, the system releases the item or lets the key decrypt it
5 App and token endpoint The app sends the refresh token over HTTPS
6 Authorisation server The server checks expiry, revocation, rotation and session policy
7 Authorisation server and app The server returns a new access token and, with rotation, a new refresh token
8 App and secure storage The app saves the replacement refresh token straight away

The backend never receives the biometric. It receives the refresh token or, in a proof-of-possession design, a signature made with a device-held key. The server remains the authority on the session.

Where the refresh actually happens

From the user's point of view, a fingerprint seems to turn a refresh token into an access token. Technically, the exchange happens on the server:

  • The app can read the protected refresh token only after local authentication.
  • The app submits it to the backend's token endpoint.
  • The backend validates the refresh token and issues a new access token.

No standard Android or iOS API turns a fingerprint into an access token. The platforms provide local authentication and protected key storage; the app's authentication protocol and the backend perform the token renewal.

Android: BiometricPrompt and Android Keystore

Android provides two building blocks, and the security comes from binding them together.

BiometricPrompt

BiometricPrompt shows the system-owned authentication dialog. The app declares which authenticators it accepts with setAllowedAuthenticators() and checks availability by passing the same combination to BiometricManager.canAuthenticate(). If nothing is enrolled, it can send the user to enrolment with ACTION_BIOMETRIC_ENROLL. The three authenticator types are:

  • BIOMETRIC_STRONG: Class 3 biometrics.
  • BIOMETRIC_WEAK: Class 2 biometrics.
  • DEVICE_CREDENTIAL: the screen-lock PIN, pattern or password.

Android's guidance is to use Credential Manager for the first sign-in on a device. Later re-authorisations can use either BiometricPrompt or Credential Manager, with BiometricPrompt offering more customisation. On Android 10 (API 29) and lower, DEVICE_CREDENTIAL on its own and the BIOMETRIC_STRONG | DEVICE_CREDENTIAL combination are not supported, so apps on those versions check for a screen lock with KeyguardManager.isDeviceSecure() instead.

Android Keystore

Android Keystore keeps key material out of the app process and prevents it from being extracted from the device. Keys can be bound to a Trusted Execution Environment or a secure element, including StrongBox on Android 9 and later devices that have one. A key can require user authentication for every cryptographic operation or for a period after the user authenticates.

Binding the prompt to a key with CryptoObject

The critical link is BiometricPrompt.CryptoObject:

  1. The app creates a key in Android Keystore with setUserAuthenticationRequired(true).
  1. On API 30 and later, it requires authentication for every use with setUserAuthenticationParameters(0, KeyProperties.AUTH_BIOMETRIC_STRONG), optionally adding AUTH_DEVICE_CREDENTIAL.
  1. The app initialises a Cipher, Signature or Mac with that key.
  1. It wraps the primitive in a BiometricPrompt.CryptoObject and passes it to authenticate().
  1. After a successful Class 3 biometric check, or a device-credential check on Android 11 and later, the cryptographic operation can proceed.

This ties the authentication event to the protected operation instead of trusting a callback. CryptoObject accepts Cipher, Signature and Mac, plus newer constructors for identity credentials, operation handles and KeyAgreement. It has limits: the AndroidX library documents that crypto-based authentication is incompatible with Class 2 biometrics and, before Android 11, with the device credential, and it throws an IllegalArgumentException if those authenticators are allowed. Keys with a validity period that accept biometrics or the screen lock cannot be passed as a CryptoObject either.

Validity periods, API levels and enrolment changes

KeyGenParameterSpec.Builder.setUserAuthenticationParameters(timeout, type) was added in API 30. A timeout of 0 means authentication is required for every use of the key, and the type accepts only AUTH_BIOMETRIC_STRONG and AUTH_DEVICE_CREDENTIAL, so there is no Class 2 option for keys. The older setUserAuthenticationValidityDurationSeconds() is deprecated from API 30, but apps that support API 23 to 29 still need it there, and on that method every-use is -1, not 0.

Biometric enrolment changes matter too. setInvalidatedByBiometricEnrollment() defaults to true, so a biometric-only key is irreversibly invalidated when a new biometric is enrolled or all biometrics are removed. Keys that also accept AUTH_DEVICE_CREDENTIAL are not invalidated this way. Using an invalidated key throws KeyPermanentlyInvalidatedException, so the app needs a recovery path that re-authenticates the user and creates a new key. Any authentication-bound key is also invalidated if the secure lock screen is disabled or forcibly reset.

What OWASP recommends

For a high-assurance build, use an authentication-per-use key for the operation that releases or decrypts the refresh token, and do not rely only on onAuthenticationSucceeded() to unlock an otherwise unprotected token. OWASP's MASTG-BEST-0036 recommends BiometricPrompt.authenticate() with a CryptoObject backed by a Keystore key that requires user authentication, a timeout of zero for sensitive operations and no long validity periods. Its companion, MASTG-BEST-0037, covers invalidating biometric keys when enrolment changes.

iOS: Local Authentication, Keychain and the Secure Enclave

Apple separates local authentication from protected item storage.

LAContext and the Face ID usage description

LAContext evaluates an authentication policy using Face ID, Touch ID or the device passcode, depending on the policy requested, the device and any installed configuration profiles. The app gets an asynchronous success or failure result and an error describing any failure. Apps that use Face ID must include NSFaceIDUsageDescription in Info.plist; without it, authorisation requests may fail. Touch ID needs no usage string.

Keychain access control

A Keychain item can carry both an accessibility class, which sets when the item is available, and an access-control policy, which sets what authentication is needed. The policy is created with SecAccessControlCreateWithFlags and stored in the item's kSecAttrAccessControl attribute. Apple's sample stores a password item protected by .userPresence with kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly, and the Keychain returns it only after a successful authentication.

The flag decides what happens when the user's biometrics change.

Access-control flag What it accepts After biometric enrolment changes Typical use
biometryCurrentSet Face ID or Touch ID only, with no passcode fallback The item is invalidated if fingers are added or removed or Face ID is re-enrolled Strict biometric unlock tied to the enrolled set
biometryAny Any enrolled Face ID or Touch ID The item stays accessible Biometric-only unlock that survives re-enrolment
userPresence Any enrolled biometric or the device passcode The item stays accessible, so a newly enrolled face or finger can unlock it Usability, with the passcode as an accepted fallback

Apple defines userPresence as equivalent to biometryAny or devicePasscode, and biometry does not even need to be enrolled. With biometryCurrentSet, adding a fingerprint or re-enrolling Face ID invalidates the item, so the user must sign in again and the app must provision a new protected credential. Choose the flag to match the recovery policy, and remember that with userPresence anyone who knows the device passcode can unlock the token.

Keychain accessibility

kSecAttrAccessibleWhenUnlocked is the default when nothing is specified, and items in that class can move to a new device through encrypted backups. kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly cannot be stored on a device without a passcode, is readable only while the device is unlocked, never migrates to another device and is deleted if the passcode is turned off. The other ThisDeviceOnly variants do not migrate either. Apple advises choosing the most restrictive option that still meets the app's needs.

Prompting once for several items

If an app needs to read more than one protected item after a single prompt, it can pass one authenticated LAContext in kSecUseAuthenticationContext. A context that has already authenticated lets the Keychain operation succeed without prompting again.

What the Secure Enclave does and does not do

The Secure Enclave works only with 256-bit NIST P-256 elliptic-curve keys that it generates itself. It cannot import existing keys and cannot hold an arbitrary token string. It supports signing and key agreement, which also enables encryption through ECIES. One design is to ECIES-encrypt the refresh token, or a symmetric key that encrypts it, to the public key of a Secure Enclave key and store the ciphertext in the Keychain. Decryption then happens inside the Secure Enclave, and only after the key's access control, for example privateKeyUsage combined with biometryCurrentSet, is satisfied.

Flutter: which package does what

Flutter has no built-in credential vault with identical biometric behaviour on every platform. Apps use plugins that call each platform's native facilities.

local_auth

The local_auth plugin, published by flutter.dev, is at version 3.0.2 (July 2026) and supports Android, iOS, macOS and Windows, but not Linux or the web. It checks device support, lists available biometrics and shows the system prompt. By default the prompt also allows the PIN, pattern or passcode; setting biometricOnly: true requires a biometric, although that option is not supported on Windows. Since 3.0.0, biometricOnly is a direct parameter of authenticate(), replacing the older AuthenticationOptions object. canCheckBiometrics reports only whether hardware support exists, while getAvailableBiometrics() returns the biometrics the app can currently use, which on iOS may exclude enrolled ones until permission is granted.

local_auth is a prompt API. It returns a yes or no answer, and nothing ties that answer to a key. Calling it and then reading a token from ordinary preferences does not protect the token. Use the result to drive the interface if you like, but put the credential itself behind Keychain or Keystore access control.

flutter_secure_storage

flutter_secure_storage 11.2.0 (September 2026) gives Dart a common interface over each platform's protected storage.

  • Android, default mode: values are encrypted with AES-GCM under a key wrapped with RSA-OAEP.
  • Android, biometric mode: AndroidOptions.biometric() wraps the storage key with an AES-GCM key in Android Keystore that requires authentication on every use.
  • Apple platforms: Keychain items with access-control flags, with optional Secure Enclave key wrapping.
  • Linux: storage through libsecret.
  • Web: experimental, and it works only over HTTPS or on localhost.

Version 11.0.0 removed the older cipher options and the EncryptedSharedPreferences option, and raised the Android minimum to API 24. Data written with the removed algorithms becomes unusable, so the changelog advises moving through version 10 first. Version 11.2.0 added requireBiometricsPerOperation.

Recommended Flutter configuration

The configuration below stores a refresh token in native protected storage. Enable it only after the user opts into biometric unlock and the app has confirmed that the device has a screen lock. With per-operation authentication, writes prompt as well as reads, so plan the first save of the credential accordingly.


            import 'package:flutter_secure_storage/flutter_secure_storage.dart';

const secureStorage = FlutterSecureStorage(
  aOptions: AndroidOptions.biometric(
    enforceBiometrics: true, // throws if there is no screen lock, or below API 28
    requireBiometricsPerOperation: true, // prompt on every read and write (11.2.0+)
    biometricType: AndroidBiometricType.biometricOrDeviceCredential,
    biometricPromptTitle: 'Unlock your session',
    biometricPromptSubtitle: 'Authenticate to continue',
  ),
  iOptions: IOSOptions(
    accessibility: KeychainAccessibility.passcode, // WhenPasscodeSetThisDeviceOnly
    accessControlFlags: [AccessControlFlag.userPresence], // or biometryCurrentSet
  ),
);
          

What each Android setting does, checked against the 11.2.0 source:

  • The fallback depends on the Android version. On Android 11 (API 30) and later, biometricOrDeviceCredential accepts a Class 3 biometric or the device PIN, pattern or password. On Android 9 and 10, only Class 3 biometrics work, whatever biometricType says.
  • Failing versus downgrading. enforceBiometrics decides what happens on unsupported devices. With it set to true, the plugin throws a BIOMETRIC_UNAVAILABLE error when the device has no screen lock or runs below Android 9 (API 28). With it set to false, the plugin skips authentication on those older versions without telling you. Keep it true and handle the error with a full sign-in.
  • Prompt frequency. requireBiometricsPerOperation prompts on every read, readAll and write, while delete and containsKey never prompt. Without it, the user sees one prompt per app process and the unlocked key stays in memory until the process ends.
  • Strict Class 3. strongBiometricOnly gives a strict Class 3 policy, fully enforced on Android 11 and later. Under Android's rules, a key that also accepts the device credential is not invalidated when a new fingerprint is enrolled, so pick the biometric-only type if enrolment changes must force a fresh sign-in.

For iOS, KeychainAccessibility.passcode maps to kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly, the same class Apple's Face ID sample uses, so the token never moves to a new phone through a backup. userPresence accepts the device passcode as a fallback; use biometryCurrentSet to require the currently enrolled biometrics and invalidate the item when they change.

The snippet leaves out useSecureEnclave: true on purpose. In the current plugin it wraps each value's key with a Secure Enclave key, but the Face ID or Touch ID gate is still the Keychain item's access control. It also accepts a Touch ID unlock from the previous 30 seconds without prompting, and it falls back to a plain Keychain read if the Secure Enclave is unavailable. It is not a guarantee that the token string sits inside the Secure Enclave.

Android setup notes

  • Permission: declare the USE_BIOMETRIC permission in AndroidManifest.xml for the plugin's biometric mode, as its documentation instructs.
  • local_auth requirements: if the app also uses local_auth for capability checks or a separate prompt, its Android implementation needs a FlutterFragmentActivity instead of FlutterActivity, the USE_BIOMETRIC permission, a LaunchTheme whose parent is a Theme.AppCompat theme to avoid crashes on Android 8 and lower, and a minimum SDK of 24.
  • Backups: the Keystore key cannot leave the device, so an encrypted credential restored onto another device cannot be decrypted. The flutter_secure_storage documentation recommends disabling Auto Backup or excluding its shared preferences, which in 11.2.0 are FlutterSecureStorage.xml, FlutterSecureKeyStorage.xml and the FlutterSecureStorageConfiguration files. Android's own guidance advises against backing up credentials and auth tokens, and on Android 12 and later the dataExtractionRules file has separate cloud-backup and device-transfer sections, so exclude the files in both. Treat a decryption failure after a restore as a signal to ask for a full sign-in.

iOS setup notes

Add a meaningful NSFaceIDUsageDescription value to ios/Runner/Info.plist, for example Use Face ID to unlock your saved sign-in session. This applies even when Face ID is triggered only by a Keychain item's access control, as it is with flutter_secure_storage, and not just by local_auth. Apple's Keychain sample tells developers to include the key in any project that uses biometrics.

Managing the refresh-token lifecycle in Flutter

A repository or service layer should own the credential lifecycle. Avoid scattering secure-storage reads and refresh calls across widgets or individual API methods.


            import 'package:flutter_secure_storage/flutter_secure_storage.dart';

class SessionRepository {
  SessionRepository(this._api, this._storage);

  final AuthApi _api;
  final FlutterSecureStorage _storage;
  static const _refreshTokenKey = 'refresh_token';
  Future<void>? _inFlight;

  Future<void> saveRefreshToken(String token) =>
      _storage.write(key: _refreshTokenKey, value: token);

  // Single-flight: concurrent callers share one refresh, so a rotated
  // refresh token is never submitted twice.
  Future<void> unlockAndRefresh() =>
      _inFlight ??= _refresh().whenComplete(() => _inFlight = null);

  Future<void> _refresh() async {
    // On a biometric-protected store, this read shows the system prompt.
    final refreshToken = await _storage.read(key: _refreshTokenKey);
    if (refreshToken == null || refreshToken.isEmpty) {
      throw const SessionRequiredException();
    }

    try {
      final response = await _api.refresh(refreshToken: refreshToken);

      // Persist a rotated refresh token immediately.
      if (response.refreshToken case final String nextToken
          when nextToken.isNotEmpty) {
        await _storage.write(key: _refreshTokenKey, value: nextToken);
      }

      // Keep the short-lived access token in memory only.
      _api.setInMemoryAccessToken(response.accessToken);
    } on InvalidGrantException {
      // Expired, revoked or reused: drop it and require a full sign-in.
      await _storage.delete(key: _refreshTokenKey);
      throw const SessionRequiredException();
    }
  }

  Future<void> logout() async {
    try {
      await _api.revokeSession(); // server-side revocation, e.g. RFC 7009
    } finally {
      await _storage.delete(key: _refreshTokenKey); // delete never prompts
      _api.clearInMemoryAccessToken();
    }
  }
}
          

The AuthApi, InvalidGrantException and SessionRequiredException types are illustrative; adapt them to the app's networking layer. The if-case with a when guard needs Dart 3. With requireBiometricsPerOperation on Android, a refresh that returns a rotated token shows two prompts, one to read the old token and one to write the new one. The rules the code follows are:

  • Treat the refresh token as a high-value credential. Never write it to logs, analytics, crash reports, URLs or exception messages.
  • Send it only to the intended token endpoint over TLS.
  • When the server issues a new refresh token, the client must discard the old one and store the replacement, which RFC 6749 requires. Persist it immediately, and route every refresh through one single-flight path so parallel calls cannot race.
  • If the server answers with invalid_grant, which covers a refresh token that is invalid, expired, revoked or issued to another client, delete the local credential and ask for a fresh sign-in.
  • On logout, delete the refresh token, clear in-memory access tokens and ask the server to revoke the session. RFC 7009 requires revocation endpoints to support refresh tokens, and revoking one should also invalidate access tokens from the same grant where the server supports it.
  • Do not assume a successful biometric prompt means the server session is still active.

What a refresh request looks like

A conventional OAuth refresh request looks like this:


            POST /oauth/token HTTP/1.1
Host: auth.example.com
Content-Type: application/x-www-form-urlencoded

grant_type=refresh_token&refresh_token=<protected-refresh-token>&client_id=<client-id>
          

A successful response may include:


            {
  "access_token": "<new-access-token>",
  "token_type": "Bearer",
  "expires_in": 900,
  "refresh_token": "<rotated-refresh-token>"
}
          

RFC 6749 makes access_token and token_type required, expires_in recommended and refresh_token optional, and token responses must carry Cache-Control: no-store. With DPoP, the request also carries a DPoP header and the response's token type is DPoP rather than Bearer. The exact endpoint and fields are backend-specific. The server decides validity and lifetime, and the app should never invent its own expiry or extend a refresh token locally.

A stronger option: bind the refresh token to a device key

For higher-risk apps, a bearer refresh token behind biometric protection may not be enough, because once it leaves the device it can be replayed. A stronger design binds the session to a device-held key:

  1. After a full sign-in, the app creates a signing key pair in Android Keystore or as a Secure Enclave key, with an access policy that requires user authentication.
  1. On each token request, the app signs a DPoP proof. The public key travels in the proof's header, so there is no separate registration step.
  1. For public clients, RFC 9449 requires the server to bind the refresh token to that public key, so every later refresh needs a proof from the same key.
  1. If the server requires a nonce, it returns a use_dpop_nonce error with a DPoP-Nonce header, and the app includes that value in its next proof.
  1. The biometric authorises the signing operation, and a copied refresh token is useless without the key.

This is still not a biometric-to-token conversion. It is proof of possession: the server gets evidence that the request was signed by the device-bound key. RFC 9700, published in January 2025 as BCP 240, requires authorisation servers to sender-constrain public clients' refresh tokens or rotate them so that replay can be detected. Biometric-protected storage meets RFC 6749's requirement to keep refresh tokens confidential in storage, but it is neither sender-constraining nor rotation, so the server-side control is still needed.

Watch the prompt count. If access tokens are also DPoP-bound, every API call needs a fresh proof, and a key that requires biometrics on every use would prompt on every request. RFC 9449 allows binding only the refresh token while access tokens stay as bearer tokens.

Native mobile apps are normally OAuth public clients. RFC 8252 says a secret compiled into a distributed app should not be treated as confidential, requires public native clients to use PKCE, and says the authorisation request should run in the system browser or an in-app browser tab, never an embedded WebView. That sign-in step is separate from the biometric unlock. The device-bound design needs coordinated backend support and is worth evaluating for financial, healthcare, enterprise and other high-impact apps where replay of a stolen refresh token is a serious concern.

Web, desktop and other cross-platform stacks

A cross-platform UI framework does not create one identical biometric security boundary everywhere. The implementation has to follow each operating system's credential and key-storage model.

  • Android and iOS: use the native prompt with the native protected key or credential store, as described above.
  • macOS and Windows: local_auth supports both, but confirm the vault and the authentication policy separately for each platform.
  • Linux: flutter_secure_storage can store data through libsecret, but local_auth does not support Linux, so there is no equivalent biometric path.
  • Web: use WebAuthn and passkeys rather than trying to reuse a mobile Keychain or Keystore design. WebAuthn Level 3 became a W3C Recommendation on 25 August 2026. Its user-verification step can use biometric recognition, a PIN or another gesture, the relying party verifies a signed assertion, and biometric data is not revealed to the relying party. flutter_secure_storage's web support is experimental, keeps its WebCrypto keys in LocalStorage and has different security properties from native storage.

The same principle applies to React Native, Kotlin Multiplatform, .NET MAUI and other stacks: use a maintained bridge to the native secure store. Never replace it with a home-grown encrypted file whose key is hardcoded in the app, which OWASP lists as a weakness in MASWE-0003.

Eight common implementation mistakes

1. Showing a biometric prompt, then reading an ordinary token

If the token remains readable by app code without authorisation, the prompt is only a UI gate. Protect the Keychain item or the Keystore operation itself.

2. Treating biometric success as backend authentication

A local success callback cannot tell whether the account was disabled, the token revoked, the session expired or the backend detected refresh-token reuse. Always validate the refresh token with the backend.

3. Keeping the token, or its key, where the app can read it unaided

Keystore stores keys, not tokens, so an encrypted token can live in SharedPreferences or a file as long as its key stays in Keystore. The mistake is a plaintext token in SharedPreferences, SQLite, Hive, GetStorage or a plain file, or a token encrypted with a key the app holds or hardcodes. OWASP's MASVS-STORAGE-1 requires sensitive data to be stored securely, MASWE-0001 covers session tokens stored unencrypted, and MASWE-0003 covers keys kept outside the platform keystore.

4. Assuming all biometrics are equal

Android distinguishes Class 2 and Class 3 biometrics. Class 2 biometrics cannot unlock a Keystore key or a CryptoObject, so a prompt that accepts them is only a UI gate. On iOS, biometryCurrentSet and userPresence behave differently. Choose the policy deliberately and provide a recovery route.

5. Assuming the biometric sets the refresh-token lifetime

The backend decides expiry. Biometrics only gate local access. Use absolute and inactivity expiry on the server and revoke sessions when required.

6. Ignoring enrolment changes and device migration

On Android, adding a biometric or removing all of them invalidates biometric-only keys. On iOS, biometryCurrentSet items are invalidated when fingers are added or removed or Face ID is re-enrolled. Removing the screen lock or passcode invalidates or deletes protected items on both platforms, and a restore onto a new phone leaves device-bound credentials unusable by design. Detect the failure, explain it clearly and require a full sign-in instead of silently downgrading to weaker storage.

7. Keeping an unlocked key open longer than intended

Authentication for every use gives a narrower window than a validity period that stays open for minutes, and flutter_secure_storage without per-operation mode keeps its unlocked key in memory for the life of the process. Choose based on the threat model and the user experience, and for high-value actions authenticate immediately before the cryptographic operation.

8. Racing refresh calls under token rotation

If two API calls refresh at the same moment, both may submit the same refresh token. Under RFC 9700, reuse of a refresh token that has already been rotated is treated as theft, and the server revokes the active refresh token as well, which can sign the user out entirely. Centralise refresh logic, serialise it and make the backend's rotation and reuse behaviour explicit.

Production checklist

  • Use a full sign-in or another approved first authentication before enabling biometric unlock.
  • Store refresh tokens only in OS-backed secure storage, or encrypted under a key held there.
  • Require authentication for the actual token read or key operation, not just for showing a screen.
  • Decide whether the device PIN or passcode is an acceptable fallback, and accept the enrolment trade-off that comes with it.
  • On Android, declare the accepted authenticators, check availability, and use Keystore-authenticated CryptoObject operations for sensitive key use.
  • On iOS, pick the Keychain accessibility class and access-control flags to match the recovery policy.
  • Keep access tokens short-lived and in memory where practical.
  • Implement server-side refresh-token expiry, revocation, rotation or sender-constraining, and reuse detection.
  • Serialise refresh calls and persist rotated tokens immediately.
  • Handle cancellation, failed biometrics, lockout, no enrolled biometrics, the device-credential fallback and biometric-set changes.
  • Delete local credentials on logout, revoke the session on the server and clear session state from memory.
  • Keep credentials out of logs, analytics, crash reports, screenshots and app-switcher previews.
  • Exclude encrypted credential files from Android cloud backup and device transfer, and handle decryption failure after a restore.
  • Test on real devices across the supported OS versions, because simulators and emulators do not prove hardware-backed behaviour.

Final recommendation

For a conventional Flutter app with email and password or OAuth sign-in, hold the refresh token in flutter_secure_storage with explicit biometric access controls, and use the backend's token endpoint to get the next access token. local_auth can help with capability checks and user-facing flows, but it should not be the only protection around a high-value token. For sensitive apps, add a device-bound signing key and a sender-constrained refresh token such as DPoP. In either design, keep backend expiry and revocation authoritative and keep the biometric policy independent of the token lifetime.

The takeaway is simple: Face ID or a fingerprint unlocks the device-protected credential, and the backend validates the session and issues the access token.

How eCorpIT can help

eCorpIT is a Gurugram-based technology organisation, founded in 2021, assessed at CMMI Level 5 and MSME certified, with senior-led engineering teams working across AWS, Microsoft and Google platforms. Our Flutter developers design and review biometric unlock, secure storage and token-refresh flows for Android and iOS apps, and test them on real devices across supported OS versions. Talk to us at /contact-us/.

Last updated: 31 October 2026.

References

  1. Android Keystore system — Android Developers
  1. Accessing keychain items with Face ID or Touch ID — Apple Developer
  1. flutter_secure_storage package — pub.dev
  1. local_auth package — pub.dev
  1. RFC 9700: Best Current Practice for OAuth 2.0 Security, section 4.14 — IETF
  1. Local Authentication framework — Apple Developer
  1. Show a biometric authentication dialog — Android Developers
  1. BiometricPrompt.CryptoObject — Android Developers
  1. androidx.biometric.BiometricPrompt — Android Developers
  1. KeyGenParameterSpec.Builder — Android Developers
  1. MASTG-BEST-0037: Invalidate biometric keys on enrollment changes — OWASP Mobile Application Security
  1. MASTG-BEST-0036: Use cryptographic binding for biometric authentication — OWASP Mobile Application Security
  1. LAContext — Apple Developer
  1. NSFaceIDUsageDescription — Apple Developer
  1. SecAccessControlCreateFlags.userPresence — Apple Developer
  1. SecAccessControlCreateFlags.biometryCurrentSet — Apple Developer
  1. Restricting keychain item accessibility — Apple Developer
  1. kSecUseAuthenticationContext — Apple Developer
  1. Protecting keys with the Secure Enclave — Apple Developer
  1. local_auth changelog — pub.dev
  1. flutter_secure_storage changelog — pub.dev
  1. AndroidOptions class, flutter_secure_storage API reference — pub.dev
  1. flutter_secure_storage source repository — GitHub
  1. IOSOptions class, flutter_secure_storage API reference — pub.dev
  1. local_auth_android setup — pub.dev
  1. Back up user data with Auto Backup — Android Developers
  1. Branches: if-case statements and guard clauses — Dart documentation
  1. RFC 6749: The OAuth 2.0 Authorization Framework — IETF
  1. RFC 7009: OAuth 2.0 Token Revocation — IETF
  1. RFC 9449: OAuth 2.0 Demonstrating Proof of Possession (DPoP) — IETF
  1. RFC 8252: OAuth 2.0 for Native Apps — IETF
  1. Web Authentication: An API for accessing Public Key Credentials, Level 3 — W3C
  1. MASWE-0003: Cryptographic keys stored outside of platform keystore — OWASP Mobile Application Security
  1. MASVS-STORAGE-1: The app securely stores sensitive data — OWASP Mobile Application Security
  1. MASWE-0001: Sensitive data stored unencrypted in private storage — OWASP Mobile Application Security

Frequently asked

Quick answers.

01 Does a fingerprint or Face ID turn a refresh token into an access token?
No. The biometric check only lets the operating system release a protected refresh token, or let a protected key decrypt it. The app then sends the refresh token to the backend's token endpoint, and the server checks expiry, revocation and rotation before issuing a new access token. No standard platform API converts a biometric into a token.
02 Is local_auth enough to secure a token in a Flutter app?
No. local_auth shows the system prompt and returns a yes or no result, but nothing ties that result to a key. If the token sits in ordinary preferences, any code in the app can read it without the prompt. Store the token behind Keychain or Keystore access control, for example with flutter_secure_storage's biometric options.
03 Which flutter_secure_storage settings should I use for a refresh token?
On Android, use the biometric options with enforcement and per-operation prompts turned on, and choose whether the device PIN is an acceptable fallback. On iOS, use the passcode accessibility class with a user-presence or current-biometric-set flag. Check the changelog for the version you install, because options changed significantly in the 11.x releases.
04 What happens when a user adds a new fingerprint or re-enrols Face ID?
It depends on the policy. Biometric-only Android keys and iOS items protected by the current biometric set are invalidated, so the app must ask for a full sign-in and create a new protected credential. Policies that also accept the device PIN or passcode survive the change, which is more convenient but less strict.
05 Should the device PIN or passcode be allowed as a fallback?
It is a product and risk decision. Allowing it helps users whose biometrics fail and avoids lockouts after re-enrolment, but anyone who knows the passcode can unlock the token. For high-value apps, require current biometrics for sensitive actions and send users through a full sign-in when biometrics change.
06 How long should access and refresh tokens last?
There is no single standard. Many apps issue access tokens for 10 to 20 minutes and refresh tokens for hours to weeks, with an inactivity limit on the server. The biometric lock timeout is a separate local setting, and a successful biometric check never extends a refresh token's lifetime.
07 What is refresh token rotation and why does it matter for mobile apps?
Rotation means the server issues a new refresh token on every refresh and invalidates the old one. Because mobile apps are public clients, the OAuth security best practice requires rotation or device binding. If an old token is reused, the server treats it as theft and revokes the session, so refresh calls must never run in parallel.
08 What should a Flutter web or desktop app use instead?
On the web, use WebAuthn and passkeys, where the browser and authenticator verify the user and the server checks a signed assertion. On macOS and Windows, local_auth works, but confirm the secure storage and policy separately for each platform. On Linux, local_auth has no support, so there is no equivalent biometric path today.

About the author

Aman Mathur

Software Engineer | Flutter

Software Engineer with 3+ years building high-performance, AI-powered cross-platform mobile apps for global clients.

Subscribe

One engineering note a week. No fluff, no spam.

Senior-architect playbooks on AI agents, mobile apps, cloud, security, data, and marketing — delivered every Wednesday.

Past the reading

Read enough. Let's build something.

A senior architect responds in 24 working hours with scope, indicative cost, and a timeline. NDA before any technical conversation.