Biometrics in Flutter: solved exercise with local_auth and fingerprint / Face ID

Biometrics in Flutter: solved exercise with local_auth and Face ID / fingerprint

local_auth lets you authenticate the user with fingerprint, Face ID, or the device PIN/pattern, without sending credentials to any server. It is ideal for protecting sensitive screens within an already-authenticated app.

Problem statement

Build a biometric authentication screen that:

  • Checks if the device supports biometrics and which types are available.
  • Requests biometric authentication with fallback to PIN/pattern.
  • Shows the result (success, failure, error) with clear feedback.
  • Combines biometrics with flutter_secure_storage to read a protected token.

Dependencies

1
2
3
dependencies:
  local_auth: ^2.3.0
  flutter_secure_storage: ^9.2.2

Android setup

In android/app/src/main/AndroidManifest.xml:

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

The main activity must use FlutterFragmentActivity:

1
2
3
4
// android/app/src/main/kotlin/.../MainActivity.kt
import io.flutter.embedding.android.FlutterFragmentActivity

class MainActivity : FlutterFragmentActivity()

iOS setup

In ios/Runner/Info.plist:

1
2
<key>NSFaceIDUsageDescription</key>
<string>We use Face ID to protect your account</string>

Full solution

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:local_auth/local_auth.dart';
import 'package:local_auth/error_codes.dart' as auth_error;

// ── Biometric service ──────────────────────────────────────────────────────────
class BiometricService {
  final _auth = LocalAuthentication();
  final _storage = const FlutterSecureStorage();
  static const _tokenKey = 'auth_token';

  Future<BiometricAvailability> checkAvailability() async {
    final canCheck = await _auth.canCheckBiometrics;
    final isDeviceSupported = await _auth.isDeviceSupported();
    if (!isDeviceSupported) return BiometricAvailability.notSupported;
    if (!canCheck) return BiometricAvailability.notEnrolled;
    return BiometricAvailability.available;
  }

  Future<List<BiometricType>> getAvailableBiometrics() =>
      _auth.getAvailableBiometrics();

  Future<AuthResult> authenticate({
    required String reason,
    bool biometricOnly = false,
  }) async {
    try {
      final success = await _auth.authenticate(
        localizedReason: reason,
        options: AuthenticationOptions(
          biometricOnly: biometricOnly,
          stickyAuth: true,
          useErrorDialogs: true,
        ),
      );
      return success ? AuthResult.success : AuthResult.failed;
    } on PlatformException catch (e) {
      return switch (e.code) {
        auth_error.notAvailable => AuthResult.notAvailable,
        auth_error.notEnrolled => AuthResult.notEnrolled,
        auth_error.lockedOut => AuthResult.lockedOut,
        auth_error.permanentlyLockedOut => AuthResult.permanentlyLockedOut,
        _ => AuthResult.error,
      };
    }
  }

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

  Future<String?> readTokenWithBiometrics() async {
    final result = await authenticate(
      reason: 'Authenticate to view your session token',
    );
    if (result != AuthResult.success) return null;
    return _storage.read(key: _tokenKey);
  }

  Future<void> deleteToken() => _storage.delete(key: _tokenKey);
}

enum BiometricAvailability { available, notSupported, notEnrolled }

enum AuthResult {
  success, failed, notAvailable, notEnrolled,
  lockedOut, permanentlyLockedOut, error,
}

// ── Main ───────────────────────────────────────────────────────────────────────
void main() => runApp(const MaterialApp(home: BiometricDemo()));

class BiometricDemo extends StatefulWidget {
  const BiometricDemo({super.key});
  @override
  State<BiometricDemo> createState() => _BiometricDemoState();
}

class _BiometricDemoState extends State<BiometricDemo> {
  final _service = BiometricService();
  BiometricAvailability? _availability;
  List<BiometricType> _biometrics = [];
  AuthResult? _lastResult;
  String? _token;
  bool _loading = false;

  @override
  void initState() {
    super.initState();
    _init();
  }

  Future<void> _init() async {
    setState(() => _loading = true);
    await _service.saveToken('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.demo');
    final availability = await _service.checkAvailability();
    final biometrics = await _service.getAvailableBiometrics();
    setState(() {
      _availability = availability;
      _biometrics = biometrics;
      _loading = false;
    });
  }

  Future<void> _authenticate() async {
    setState(() => _loading = true);
    final result = await _service.authenticate(
      reason: 'Confirm your identity to access the app',
    );
    setState(() { _lastResult = result; _loading = false; });
  }

  Future<void> _readToken() async {
    setState(() => _loading = true);
    final token = await _service.readTokenWithBiometrics();
    setState(() { _token = token; _loading = false; });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Biometrics with local_auth')),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            _DeviceInfo(
              availability: _availability,
              biometrics: _biometrics,
              loading: _loading,
            ),
            const Divider(height: 32),
            FilledButton.icon(
              onPressed: (_availability == BiometricAvailability.available && !_loading)
                  ? _authenticate : null,
              icon: const Icon(Icons.fingerprint),
              label: const Text('Authenticate with biometrics'),
            ),
            const SizedBox(height: 8),
            FilledButton.icon(
              onPressed: (_availability == BiometricAvailability.available && !_loading)
                  ? _readToken : null,
              icon: const Icon(Icons.lock_open),
              label: const Text('Read protected token'),
              style: FilledButton.styleFrom(backgroundColor: Colors.teal),
            ),
            const SizedBox(height: 24),
            if (_lastResult != null) ...[
              _ResultCard(result: _lastResult!),
              const SizedBox(height: 16),
            ],
            if (_token != null) ...[
              const Text('Token retrieved from Secure Storage:',
                  style: TextStyle(fontWeight: FontWeight.bold)),
              const SizedBox(height: 4),
              Container(
                padding: const EdgeInsets.all(12),
                decoration: BoxDecoration(
                  color: Colors.green.shade50,
                  borderRadius: BorderRadius.circular(8),
                  border: Border.all(color: Colors.green.shade200),
                ),
                child: SelectableText(
                  _token!,
                  style: const TextStyle(fontFamily: 'monospace', fontSize: 12),
                ),
              ),
            ],
            const Spacer(),
            _BiometricLegend(biometrics: _biometrics),
          ],
        ),
      ),
    );
  }
}

class _DeviceInfo extends StatelessWidget {
  final BiometricAvailability? availability;
  final List<BiometricType> biometrics;
  final bool loading;
  const _DeviceInfo({required this.availability, required this.biometrics, required this.loading});

  @override
  Widget build(BuildContext context) {
    if (loading && availability == null) return const Center(child: CircularProgressIndicator());
    final (icon, label, color) = switch (availability) {
      BiometricAvailability.available => (Icons.check_circle, 'Biometrics available', Colors.green),
      BiometricAvailability.notEnrolled => (Icons.warning_amber, 'No biometrics configured on device', Colors.orange),
      BiometricAvailability.notSupported => (Icons.error, 'Device does not support biometrics', Colors.red),
      _ => (Icons.help, 'Checking...', Colors.grey),
    };
    return Row(
      children: [
        Icon(icon, color: color, size: 32),
        const SizedBox(width: 12),
        Expanded(
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Text(label, style: TextStyle(color: color, fontWeight: FontWeight.bold)),
              if (biometrics.isNotEmpty)
                Text('Types: ${biometrics.map(_biometricName).join(', ')}',
                    style: const TextStyle(fontSize: 12, color: Colors.grey)),
            ],
          ),
        ),
      ],
    );
  }

  String _biometricName(BiometricType t) => switch (t) {
    BiometricType.face => 'Face ID',
    BiometricType.fingerprint => 'Fingerprint',
    BiometricType.iris => 'Iris',
    BiometricType.strong => 'Strong biometrics',
    BiometricType.weak => 'Weak biometrics',
    _ => t.toString(),
  };
}

class _ResultCard extends StatelessWidget {
  final AuthResult result;
  const _ResultCard({required this.result});

  @override
  Widget build(BuildContext context) {
    final (icon, label, color) = switch (result) {
      AuthResult.success => (Icons.check_circle, '✅ Authentication successful', Colors.green),
      AuthResult.failed => (Icons.cancel, '❌ Authentication failed', Colors.red),
      AuthResult.lockedOut => (Icons.lock, '🔒 Too many failed attempts — wait a moment', Colors.orange),
      AuthResult.permanentlyLockedOut => (Icons.lock_person, '🚫 Biometrics locked — use device PIN', Colors.red),
      AuthResult.notEnrolled => (Icons.fingerprint, '⚠️ No biometrics configured on device', Colors.orange),
      AuthResult.notAvailable => (Icons.block, '⚠️ Biometrics not available', Colors.grey),
      _ => (Icons.error, '❌ Unknown error', Colors.red),
    };
    return Container(
      padding: const EdgeInsets.all(12),
      decoration: BoxDecoration(
        color: color.withOpacity(0.1),
        borderRadius: BorderRadius.circular(8),
        border: Border.all(color: color.withOpacity(0.3)),
      ),
      child: Row(
        children: [
          Icon(icon, color: color),
          const SizedBox(width: 8),
          Expanded(child: Text(label, style: TextStyle(color: color))),
        ],
      ),
    );
  }
}

class _BiometricLegend extends StatelessWidget {
  final List<BiometricType> biometrics;
  const _BiometricLegend({required this.biometrics});

  @override
  Widget build(BuildContext context) {
    return Wrap(
      spacing: 8,
      children: [
        if (biometrics.contains(BiometricType.fingerprint))
          const Chip(avatar: Icon(Icons.fingerprint, size: 16), label: Text('Fingerprint', style: TextStyle(fontSize: 11))),
        if (biometrics.contains(BiometricType.face))
          const Chip(avatar: Icon(Icons.face, size: 16), label: Text('Face ID', style: TextStyle(fontSize: 11))),
        if (biometrics.contains(BiometricType.iris))
          const Chip(avatar: Icon(Icons.remove_red_eye, size: 16), label: Text('Iris', style: TextStyle(fontSize: 11))),
      ],
    );
  }
}

local_auth error codes

Error codeCauseSolution
notAvailableHardware not availableCheck with canCheckBiometrics
notEnrolledNo fingerprint/Face ID set upTell user to configure biometrics
lockedOutToo many failed attemptsWait for automatic timeout
permanentlyLockedOutDefinitively lockedUse device PIN/pattern

Common mistakes

  • Not changing FlutterActivity to FlutterFragmentActivity: local_auth on Android requires FlutterFragmentActivity. If you don’t change it, biometrics silently fail.
  • biometricOnly: true without fallback: if the user has no biometrics available and biometricOnly is true, authentication fails immediately. Use biometricOnly: false to allow PIN fallback.
  • Storing tokens in SharedPreferences instead of SecureStorage: session tokens must be stored in flutter_secure_storage (uses Keychain on iOS and Keystore on Android), never in SharedPreferences, which is readable without root.

Practical use

Biometrics are used in: mobile banking (transfer confirmation), health apps (medical history access), password managers, and any app with sensitive data where the user has already authenticated with a password at login.

Guided practice and next step

FAQ

Does biometrics work on the Android emulator?

Yes. Go to emulator Settings → Security → Fingerprint → Enroll. You can then simulate a fingerprint touch from the emulator’s extended controls menu.

Does local_auth store fingerprints or Face ID in the app?

No. Never. Biometrics are processed entirely in the device’s secure chip (Secure Enclave on iOS, TEE on Android). The app only receives a success/failure boolean.

Can I use biometrics to protect only a section of the app?

Yes. The standard pattern is: user authenticates with username/password at login → token saved in SecureStorage → for sensitive operations (transfers, viewing card data), biometrics are requested as a second factor without re-entering the password.