Biometric Implementation Guide in Flutter + Supabase Projects
Índice
- 1. 1. Dependencies
- 1.1. 1.1 local_auth
- 1.1.1. Installation
- 1.1.2. Supported platforms
- 1.2. 1.2 flutter_secure_storage
- 1.2.1. Installation
- 1.2.2. Supported platforms
- 1.2.3. Typical usage
- 1.3. 1.3 Secondary dependencies and adjustments
- 1.4. 1.4 Important Note (Supabase)
- 2. 2. Platform Adjustments
- 2.1. 2.1 Android
- 2.1.1. 2.1.1 Change in MainActivity
- 2.1.1.1. Solution
- 2.1.2. 2.1.2 Permissions in AndroidManifest.xml
- 2.1.3. 2.1.3 SDK Compatibility
- 2.1.4. 2.1.4 Testing on devices/emulators
- 2.2. Summary:
- 3. 3. Service Layer
- 3.1. 3.1 BiometricService
- 3.1.1. Function
- 3.1.2. Main methods
- 3.1.3. Simplified example
- 3.2. 3.2 SecureStorageService
- 3.2.1. Function
- 3.2.2. Main methods
- 3.2.3. Simplified example
- 3.3. 3.3 Biometrics + Storage Integration
- 4. 3.4 Summary
- 5. 4. Repository Layer
- 5.1. 4.1 Interface IBiometricRepository
- 5.2. 4.2 Implementation BiometricRepository
- 5.3. 4.3 Responsibilities of each method
- 5.4. 4.4 Important point (Supabase limitation)
- 6. 4.5 Summary
- 7. 5. Use Case / AuthService
- 7.1. 5.1 Current Supabase situation (GoTrue v2, 2025)
- 7.2. 5.2 Implication for biometric login
- 7.3. 5.3 Implemented solution
- 7.3.1. Initial login flow (email/password)
- 7.3.2. Biometric login flow (from the second boot onwards)
- 7.4. 5.4 Advantages of this approach
- 7.5. 5.5 Limitations
- 8. 5.6 Summary
- 9. 6. UI / ViewModel
- 9.1. 6.1 ViewModel
- 9.2. 6.2 UI
- 9.3. 6.3 Summary
- 10. 7. Conclusions
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 extendFlutterFragmentActivity
:class MainActivity : FlutterFragmentActivity()
- Without this,
local_auth
fails with:PlatformException(no_fragment_activity, local_auth plugin requires activity to be a FragmentActivity.)
- Change
- 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 insetSession()
. - 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, onlyUSE_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.
- Use
- On physical devices, simply register fingerprint/face in system settings.
Summary:
- Change
MainActivity
toFlutterFragmentActivity
. - 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
andisDeviceSupported
.
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:
- After normal login, save credentials in
SecureStorageService
. - 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 callssignInWithPassword()
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 inauth.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:
- Save refresh token after login.
- Validate biometrics.
- 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)
- User logs in via
signInWithPassword()
. - Supabase returns a
Session
with anaccessToken
and arefreshToken
invalid for reuse. - The app saves the user credentials (
email
andpassword
) inSecureStorage
, encrypted and protected by biometrics.
Biometric login flow (from the second boot onwards)
- The user opens the app.
- The initial screen checks whether credentials exist in storage (
hasBiometricData()
). - If positive → calls
BiometricService.authenticate()
. - If successful → the app reads the stored credentials.
- 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 ofsignInWithPassword()
. - 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 Command
s and reacts to their states (running
, result
, listeners).
6.1 ViewModel
In SignInViewModel
, the methods related to biometrics are transformed into Command
s:
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
.
- Verifies if credentials are stored in
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
oncheckBiometricAvailability
,checkBiometricData
, andbiometricLogin
.- 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.
- Only displayed if:
- Executing biometric login
- On tap →
biometricLogin.execute("Authenticate with biometrics...")
. - While
running == true
→ shows aCircularProgressIndicator
. - On completion:
- Success → redirects to
Routes.events
. - Failure → shows a
SnackBar
orBottomSheetMessage
with the reason (canceled, no session, generic error).
- Success → redirects to
- On tap →
6.3 Summary
- ViewModel:
- Exposes only Commands, encapsulating use cases (
loginWithBiometrics
,storeBiometricData
, etc.). - Contains no UI logic, only translates results into
Result<void>
.
- Exposes only Commands, encapsulating use cases (
- 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.
- Mandatory fallback
- Email/password login must remain available.
- Biometrics should be seen as a secure shortcut, not as a unique authentication replacement.
- 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.
- In Sports, logout ends the current session but may keep credentials in
- 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 inauth.setSession()
. - Therefore, biometrics today only unlock stored credentials (email/password) and re-executes login, instead of restoring a session.
- 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.
- Once Supabase releases persistent refresh tokens, it will be possible to simplify:
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.
Leave a Reply