In this guide, I document the implementation of Biometrics in a personal project, recording the technical details and the decisions made during the process.

This documentation was produced with the assistance of ChatGPT, based on the changes applied to the project’s code, discussions about the use of biometrics with Supabase, and references to the official documentation of the tools involved.

The goal is to gather all relevant information about this implementation into a single reference material, serving as a guide for future reproductions or adaptations of this functionality in other projects.

The project follows a layered architecture based on the MVVM (Model–View–ViewModel) pattern, aligned with the recommendations of the official Flutter Team architecture guide: Flutter App Architecture Guide. This choice enables a clear separation of infrastructure, business logic, and interface, making the code more modular, testable, and adaptable for future evolutions.


1. Dependencies

The biometric implementation in Sports is based on two main Flutter libraries:

1.1 local_auth

  • Function: provides the native interface for biometric authentication on Android (fingerprint, facial recognition) and iOS (FaceID, TouchID).
  • Why use:
    • It is the official Flutter package for biometrics.
    • Abstracts the differences between platforms.
  • Limitations:
    • Only reports whether authentication was successful or not.
    • Does not return a token or identity — it only grants access.
    • Requires Android Activity to be FlutterFragmentActivity.

Installation

dependencies:
  local_auth: ^2.3.0

Supported platforms

  • Android → Fingerprint, Face Unlock (when available), Iris.
  • iOS → Face ID, Touch ID.
  • Windows → experimental support.

1.2 flutter_secure_storage

  • Function: provides secure data storage on the device, using:
    • Keychain (iOS).
    • Keystore (Android).
  • Why use:
    • Stores credentials or tokens locally, in an encrypted way.
    • The only reliable way to persist sensitive data across sessions.

Installation

dependencies:
  flutter_secure_storage: ^4.2.1

Supported platforms

  • Android, iOS, Linux, macOS, Windows.

Typical usage

  • Save user credentials after login with email/password.
  • Retrieve these credentials after biometric authentication.

1.3 Secondary dependencies and adjustments

In addition to the two main ones, some extra adjustments were required:

  • Android
    • Change MainActivity to extend FlutterFragmentActivity: class MainActivity : FlutterFragmentActivity()
    • Without this, local_auth fails with: PlatformException(no_fragment_activity, local_auth plugin requires activity to be a FragmentActivity.)
  • Linux
    • flutter_secure_storage must be registered in:
      • linux/flutter/generated_plugin_registrant.cc
      • linux/flutter/generated_plugins.cmake
  • Flutter SDK
    • local_auth requires a minimum updated SDK. In Sports, flutter was adjusted to >=3.35.0.

1.4 Important Note (Supabase)

Supabase currently does not provide reusable refresh tokens (GoTrue v2).

  • The field session.refreshToken only returns a short identifier (abc123xyz), which cannot be used in setSession().
  • Therefore, the chosen solution was to store the user’s credentials (email/password) in flutter_secure_storage, protected by biometrics.
  • In biometric login → the credentials are unlocked → signInWithPassword() is executed again.
  • In practice, biometrics currently work as a secure shortcut for login with credentials, not as session restoration via refresh token.

2. Platform Adjustments

2.1 Android

2.1.1 Change in MainActivity

The local_auth plugin depends on using FragmentActivity, as biometric authentication requires a fragment manager to handle the system’s native dialogs.

By default, Flutter generates a MainActivity that extends FlutterActivity. This causes the error:

PlatformException(no_fragment_activity, local_auth plugin requires activity to be a FragmentActivity., null, null)
Solution

Edit the file: android/app/src/main/kotlin/<your_package>/MainActivity.kt

Replace:

import io.flutter.embedding.android.FlutterActivity

class MainActivity: FlutterActivity() {
}

With:

import io.flutter.embedding.android.FlutterFragmentActivity

class MainActivity : FlutterFragmentActivity()

If the project is in Java, the same applies:

import io.flutter.embedding.android.FlutterFragmentActivity;

public class MainActivity extends FlutterFragmentActivity {
}

2.1.2 Permissions in AndroidManifest.xml

local_auth usually does not require extra permissions, as it relies on the native system. But in older Android versions, it may be necessary to ensure:

<uses-permission android:name="android.permission.USE_BIOMETRIC"/>
<uses-permission android:name="android.permission.USE_FINGERPRINT"/>

Note: USE_FINGERPRINT is legacy, but still useful for Android < 10. In newer versions, only USE_BIOMETRIC is required.

2.1.3 SDK Compatibility

  • In android/app/build.gradle.kts, make sure to use:
defaultConfig {
    minSdk = 23   // required for local_auth
    targetSdk = 34
}

2.1.4 Testing on devices/emulators

  • Emulators do not support biometrics by default.
  • To test:
    • Use adb to simulate fingerprint touch: adb -e emu finger touch 1
    • Or enable Fingerprint in the AVD Manager.
  • On physical devices, simply register fingerprint/face in system settings.

Summary:

  • Change MainActivity to FlutterFragmentActivity.
  • Ensure permissions (USE_BIOMETRIC, USE_FINGERPRINT).
  • Use minSdk >= 23.
  • Test on a physical device to validate the flow.

3. Service Layer

The Service layer is responsible for directly handling external libraries (local_auth, flutter_secure_storage) and exposing a simple, consistent API for the rest of the application. It should not contain business logic, only adaptation between the app and the SDK/platform.

3.1 BiometricService

Function

Encapsulate the interaction with the local_auth package. This ensures that the rest of the application does not directly depend on the plugin.

Main methods

  • isAvailable()
    • Checks whether the device supports biometrics and whether any biometrics are registered.
    • Combines canCheckBiometrics and isDeviceSupported.
  • authenticate(reason)
    • Displays the system’s native biometric authentication prompt.
    • Returns true/false depending on success or failure.
    • Accepts a reason parameter, which is displayed in the native dialog (e.g., “Use your fingerprint to continue”).

Simplified example

import 'package:local_auth/local_auth.dart';

class BiometricService {
  final LocalAuthentication _auth = LocalAuthentication();

  Future<bool> isAvailable() async {
    try {
      final canCheck = await _auth.canCheckBiometrics;
      final supported = await _auth.isDeviceSupported();
      return canCheck && supported;
    } catch (_) {
      return false;
    }
  }

  Future<bool> authenticate({String reason = 'Authenticate to continue'}) async {
    try {
      return await _auth.authenticate(
        localizedReason: reason,
        options: const AuthenticationOptions(
          biometricOnly: true,
          stickyAuth: true,
        ),
      );
    } catch (_) {
      return false;
    }
  }
}

3.2 SecureStorageService

Function

Manage secure storage of sensitive information on the device, using flutter_secure_storage.

This is where credentials are stored (currently, email and password, since Supabase does not provide reusable refresh tokens).

Main methods

  • saveString(key, value)
    • Saves encrypted data in the Keychain (iOS) or Keystore (Android).
  • readString(key)
    • Retrieves the stored value.
  • delete(key)
    • Removes the stored value.

Simplified example

import 'package:flutter_secure_storage/flutter_secure_storage.dart';

class SecureStorageService {
  final FlutterSecureStorage _storage = const FlutterSecureStorage();

  Future<void> saveString(String key, String value) async {
    await _storage.write(key: key, value: value);
  }

  Future<String?> readString(String key) async {
    return await _storage.read(key: key);
  }

  Future<void> delete(String key) async {
    await _storage.delete(key: key);
  }
}

3.3 Biometrics + Storage Integration

The expected flow is as follows:

  1. After normal login, save credentials in SecureStorageService.
  2. When the user opts for biometric login:
    • BiometricService.authenticate() → if successful,
    • SecureStorageService.readString() → retrieves stored credentials,
    • AuthService.signInWithPassword() → re-executes login with Supabase.

3.4 Summary

  • BiometricService → interaction with biometric hardware.
  • SecureStorageService → secure storage of credentials.
  • Together, they allow biometrics to be used as a “key” to unlock credentials and re-execute login.

4. Repository Layer

The repository serves as a facade for biometric and secure storage services, exposing high-level operations to the application domain. Here we no longer deal with local_auth or flutter_secure_storage details, but with business functions: saving credentials, checking availability, and authenticating.

4.1 Interface IBiometricRepository

The interface defines the methods that the application can call, ensuring implementation independence.

abstract class IBiometricRepository {
  Future<void> storeBiometricData(String data);
  Future<String?> authenticateWithBiometrics(String reason);
  Future<bool> hasBiometricData();
}

4.2 Implementation BiometricRepository

This is where the real integration between BiometricService and SecureStorageService takes place.

import '/data/services/biometric/biometric_service.dart';
import '/data/services/secure_storage/secure_storage_service.dart';

class BiometricRepository implements IBiometricRepository {
  final BiometricService _biometricService;
  final SecureStorageService _storageService;

  static const _tokenKey = 'biometric_token';

  BiometricRepository({
    required BiometricService biometricService,
    required SecureStorageService storageService,
  })  : _biometricService = biometricService,
        _storageService = storageService;

  @override
  Future<void> storeBiometricData(String data) async {
    await _storageService.saveString(_tokenKey, data);
  }

  @override
  Future<String?> authenticateWithBiometrics(String reason) async {
    final available = await _biometricService.isAvailable();
    if (!available) return null;

    final authenticated = await _biometricService.authenticate(reason: reason);
    if (!authenticated) return null;

    return await _storageService.readString(_tokenKey);
  }

  @override
  Future<bool> hasBiometricData() async {
    final stored = await _storageService.readString(_tokenKey);
    return stored != null;
  }
}

4.3 Responsibilities of each method

  • storeBiometricData(String data)
    • Executed after normal login.
    • Saves the credential (currently, email/password or serialized DTO) securely on the device.
  • authenticateWithBiometrics()
    • First validates if biometrics are available.
    • Executes biometric authentication.
    • If successful, returns the previously stored credential.
    • This credential is then used to re-login with Supabase.
  • hasBiometricData()
    • Simply checks whether any data is stored.
    • Useful to decide if the biometric login button should be displayed on the login screen.

4.4 Important point (Supabase limitation)

Due to the current limitation of Supabase (which does not provide reusable refresh tokens), the stored credential is not the refresh token, but the email/password pair or a DTO with login data.

  • This means that the authenticateWithBiometrics() method returns those credentials.
  • The use case layer (AccountManagementUseCase) then calls signInWithPassword() again to restore the session.

4.5 Summary

The BiometricRepository encapsulates biometrics + secure storage and exposes three simple operations:

  • Save credentials.
  • Validate biometrics and retrieve credentials.
  • Check whether biometric login data exists.

5. Use Case / AuthService

The Use Case / AuthService layer is where business logic is applied. Here we connect the BiometricRepository with the UserAuthRepository (which communicates with Supabase).

5.1 Current Supabase situation (GoTrue v2, 2025)

  • Supabase uses GoTrue v2 as the authentication provider.
  • Unlike previous versions, it does not generate reusable refresh tokens.
  • The session.refreshToken field only returns a short ID (e.g., lcixpkoex3e2), which does not work in auth.setSession().
  • When trying to use this value in setSession(), the server returns: AuthApiException(message: Invalid Refresh Token: Refresh Token Not Found)
  • Consequence: it is not possible to restore the session using only the refresh token.

5.2 Implication for biometric login

  • In systems that support refresh tokens, the normal flow would be:
    1. Save refresh token after login.
    2. Validate biometrics.
    3. Use refresh token in setSession() → restore session.
  • But since this is not supported by Supabase, another approach had to be adopted.

5.3 Implemented solution

Initial login flow (email/password)

  1. User logs in via signInWithPassword().
  2. Supabase returns a Session with an accessToken and a refreshToken invalid for reuse.
  3. The app saves the user credentials (email and password) in SecureStorage, encrypted and protected by biometrics.

Biometric login flow (from the second boot onwards)

  1. The user opens the app.
  2. The initial screen checks whether credentials exist in storage (hasBiometricData()).
  3. If positive → calls BiometricService.authenticate().
  4. If successful → the app reads the stored credentials.
  5. Re-executes signInWithPassword() with Supabase, recreating the session.

This means that “biometric login” is, in practice, a secure shortcut to reuse email/password, without the user having to type them again.

5.4 Advantages of this approach

  • Keeps user experience smooth: just use biometrics to log in.
  • Does not depend on resources not yet available in Supabase.
  • Uses Secure Storage to protect credentials.
  • Allows fallback: if biometrics fail, the user can always type email/password.

5.5 Limitations

  • Email and password are stored locally (even though encrypted).
  • If Supabase in the future enables real refresh tokens, this part of the flow will need to be refactored to use setSession() instead of signInWithPassword().
  • If the user changes their password, the stored credentials become invalid → requiring manual login again.

5.6 Summary

In the Use Case/AuthService layer, the role of biometrics is to unlock securely stored credentials and reuse them for a new login in Supabase, working around the current limitation of the platform.


6. UI / ViewModel

The ViewModel layer exposes Commands that encapsulate asynchronous calls to the AccountManagementUseCase. The UI is not aware of service or repository details: it only executes the Commands and reacts to their states (running, result, listeners).

6.1 ViewModel

In SignInViewModel, the methods related to biometrics are transformed into Commands:

  • checkBiometricAvailability → executes _accountManagementUseCase.canUseBiometrics().
    • Indicates whether the device supports biometrics.
    • In case of failure, returns CanNotUseBiometricsException.
  • checkBiometricData → executes _accountManagementUseCase.hasBiometricData().
    • Verifies if credentials are stored in SecureStorage.
    • If none, returns CanNotUseBiometricsException.
  • biometricLogin → executes _accountManagementUseCase.loginWithBiometrics(String reason).
    • Requests biometric authentication.
    • If successful → retrieves stored credentials and re-authenticates with Supabase.
    • If it fails → exposes the exception to the UI.
  • storeBiometricData → executes _accountManagementUseCase.storeBiometricData(String data).
    • Responsible for saving credentials after a manual email/password login.

Thus, the ViewModel is just an orchestration bridge, with no authentication logic.

6.2 UI

In SignInView, biometrics are integrated conditionally:

  • Listening to Commands
    • addListener on checkBiometricAvailability, checkBiometricData, and biometricLogin.
    • Updates local variables (_biometricAvailable, _hasBiometricData) according to results.
  • Rendering the biometric button
    • Only displayed if:
      • Device supports biometrics.
      • Credentials are stored.
    • Otherwise → faded or hidden icon.
  • Executing biometric login
    • On tap → biometricLogin.execute("Authenticate with biometrics...").
    • While running == true → shows a CircularProgressIndicator.
    • On completion:
      • Success → redirects to Routes.events.
      • Failure → shows a SnackBar or BottomSheetMessage with the reason (canceled, no session, generic error).

6.3 Summary

  • ViewModel:
    • Exposes only Commands, encapsulating use cases (loginWithBiometrics, storeBiometricData, etc.).
    • Contains no UI logic, only translates results into Result<void>.
  • UI:
    • Decides when to display the biometric button.
    • Executes the Commands and reacts to their states (running, Success, Failure).
    • Provides clear user feedback (loading, success, failure).

Thus, biometrics in Sports strictly follow the Command + MVVM pattern, keeping separation between infrastructure, business logic, and interface.


7. Conclusions

Biometrics were integrated into Sports as a feature of convenience and additional security, but its exact role depends on the application context.

  1. Mandatory fallback
    • Email/password login must remain available.
    • Biometrics should be seen as a secure shortcut, not as a unique authentication replacement.
  2. Logout and stored credentials
    • In Sports, logout ends the current session but may keep credentials in SecureStorage to allow subsequent biometric login.
    • This behavior is acceptable in apps where convenience outweighs absolute security.
    • In applications dealing with sensitive data (e.g., banking, healthcare), logout should fully clear SecureStorage, preventing any reentry without full authentication.
  3. Supabase limitation
    • Supabase (GoTrue v2, 2025) still does not provide reusable refresh tokens.
    • The session.refreshToken field only returns a short identifier, which does not work in auth.setSession().
    • Therefore, biometrics today only unlock stored credentials (email/password) and re-executes login, instead of restoring a session.
  4. Future perspective
    • Once Supabase releases persistent refresh tokens, it will be possible to simplify:
      • Store only the refresh token.
      • Restore the session via auth.setSession(refreshToken).
    • This will provide greater security without storing email/password on the device.

Biometrics in Sports are currently a practical solution that increases login convenience and protects credentials on the device. However, the logout and storage strategy must always be adjusted to the sensitivity level of the application’s data: for common apps, keeping credentials may be acceptable; for critical apps, the correct approach is to clear everything at logout.