Skip to content
Blog

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:

AndroidiOSmacOSWindows
SupportSDK 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.

Sources