local_auth on Desktop and Beyond: Windows Hello, macOS, and Graceful Degradation
The local_auth 3.0.2 platform matrix, what biometricOnly means on Windows, LocalAuthException codes, per-platform dialog text, and a clean fallback for web and Linux.
Published on • November 13, 2026
AI Assistant

biometric-authentication-flutter-local-auth walks the Android/iOS path end to end. This post covers what happens outside the phone: the official local_auth package (v3.0.2, published by the verified flutter.dev publisher) now spans Android, iOS, macOS, and Windows — and each platform has different rules about capability checks, exceptions, and what to do when the plugin simply doesn’t exist.
The platform matrix
From the package README:
| Android | iOS | macOS | Windows | |
|---|---|---|---|---|
| Support | SDK 24+ | 13.0+ | 10.15+ | Windows 10+ |
There is no Linux and no web implementation. The package is federated — local_auth_android, local_auth_darwin, and local_auth_windows sit behind local_auth_platform_interface (the exact structure endorsed-federated-plugin-flutter describes) — so the four supported platforms each get native-quality handling behind one Dart API. Calling authenticate() anywhere else is where a feature flag becomes mandatory, not optional.
Three checks, three different questions
The README separates the questions that tutorials blur together:
final auth = LocalAuthentication();
final canBiometrics = await auth.canCheckBiometrics; // hardware exists?
final supported = await auth.isDeviceSupported(); // any local auth at all?
final enrolled = await auth.getAvailableBiometrics(); // what's actually set up?
canCheckBiometrics says nothing about enrollment — a device with fingerprint hardware but no fingerprint enrolled still returns true. getAvailableBiometrics() lists what’s enrolled: face, fingerprint, weak, strong. The README’s own advice: don’t rely on specific types, just check that some biometric is enrolled, because the type vocabulary is device- and platform-specific.
Windows is the odd one out
biometricOnly: true — the option that prevents PIN/passcode fallback on mobile — is not supported on Windows, because the underlying Windows Hello API doesn’t let the caller choose the authentication method. A user with only a PIN configured still gets a valid authentication on Windows. Design your product logic accordingly: on Windows, “authenticated” may mean Hello PIN, not biometrics, so don’t gate a “biometric-only” feature on this returning true.
Windows also uses AuthMessagesWindows for dialog customization, and enrollment/lockout policy is owned by Windows Hello itself, not by your app.
Exceptions: read the code, not the message
Since 3.x, authenticate() throws LocalAuthException with a typed code:
try {
final ok = await auth.authenticate(
localizedReason: 'Unlock your vault',
biometricOnly: true,
);
} on LocalAuthException catch (e) {
switch (e.code) {
case LocalAuthExceptionCode.noBiometricHardware:
return _showPasswordFallback();
case LocalAuthExceptionCode.temporaryLockout:
case LocalAuthExceptionCode.biometricLockout:
return _showRetryLater();
default:
rethrow;
}
}
The lockout codes are the important ones: they’re the OS telling you the user has burned their attempts, and they’re your signal to route to a password flow rather than re-prompting. (Older code samples on the web still show PlatformException string codes and stickyAuth — the current API is LocalAuthException with persistAcrossBackgrounding replacing the old sticky-auth naming.)
Dialog text is per platform, per locale
AuthMessages is a list of platform-specific classes — you must import the platform packages to construct them:
import 'package:local_auth_android/local_auth_android.dart';
import 'package:local_auth_darwin/local_auth_darwin.dart';
await auth.authenticate(
localizedReason: 'Verify it’s you to continue',
authMessages: const <AuthMessages>[
AndroidAuthMessages(signInTitle: 'Verify identity', cancelButton: 'Use password'),
IOSAuthMessages(cancelButton: 'Use password'),
],
);
localizedReason is the string the OS dialog shows — it must be localized in your app’s l10n, since the OS can’t translate your reason. persistAcrossBackgrounding: true handles the phone-call-during-auth case: instead of failing when the app backgrounds, the plugin waits and retries when foregrounded.
Degrade on web and Linux — on purpose
Since there’s no implementation on those platforms, gate before you call:
final canLock = !kIsWeb &&
(defaultTargetPlatform == TargetPlatform.windows ||
defaultTargetPlatform == TargetPlatform.android ||
defaultTargetPlatform == TargetPlatform.iOS ||
defaultTargetPlatform == TargetPlatform.macOS) &&
(await auth.isDeviceSupported());
Then branch the UI: biometric gate on supported platforms, password/passcode everywhere else. Two rules from the architecture guidance still apply — never make local auth your only authentication (it proves device possession, not identity to your server), and remember the plugin gates local UI, not your API session.
Takeaway: Check isDeviceSupported() before offering the gate, remember biometricOnly is a no-op on Windows Hello, switch on LocalAuthExceptionCode — especially lockout — localize localizedReason yourself, and ship an explicit password fallback for web and Linux where no implementation exists.