Biometrická autentifikácia v .NET MAUI: Face ID, Touch ID a BiometricPrompt (2026)

Postavte produkčnú biometrickú autentifikáciu v .NET MAUI 10 cez natívne LAContext a androidx.biometric. Viazanie tokenov na Secure Enclave a StrongBox, ošetrenie chýb a testovanie na simulátoroch.

Biometrická autentifikácia .NET MAUI 2026

Aktualizované: 15. augusta 2026

Biometrická autentifikácia v .NET MAUI sa v roku 2026 rieši volaním natívneho LAContext na iOS a triedy BiometricPrompt z knižnice androidx.biometric na Androide cez vlastný handler alebo partial class. Plugin Plugin.Fingerprint je stále populárny, ale jeho API zaostáva za novými požiadavkami App Store a Play Store na viazanie kľúčov v Secure Enclave a StrongBox. V tomto článku ukážem, ako postaviť produkčnú biometrickú autentifikáciu v .NET MAUI 10, ktorá skutočne chráni access tokeny, a nie len otvára prihlasovaciu obrazovku. Píšem to z vlastnej skúsenosti, keď som pred pár mesiacmi na fintech projekte zisťoval, prečo App Review zamietol našu prvú verziu.

  • Apple LocalAuthentication a androidx BiometricPrompt sú od 2024 jediné akceptované cesty pre bankové a fintech aplikácie v oboch obchodoch.
  • Bez kľúča odvodeného biometriou je „prihlásenie odtlačkom prsta" len UX kozmetika. Token vo SecureStorage sa dá prečítať aj po zrušení biometrie.
  • Na iOS potrebujete NSFaceIDUsageDescription v Info.plist, inak aplikáciu App Store zamietne s chybou ITMS-90683 už pri uploade.
  • Android 11+ vyžaduje BiometricManager.Authenticators.BIOMETRIC_STRONG, ak chcete použiť setUserAuthenticationRequired(true) na kľúči v Keystore.
  • Plugin.Fingerprint 3.x funguje pre základné scenáre, ale nepodporuje CryptoObject, teda kryptografické viazanie. Na vážnu bezpečnosť ho nepoužívajte.
  • Vývojárske simulátory (iOS Simulator, Android emulator) biometriu emulujú spoľahlivo, ale StrongBox a Secure Enclave testujte len na reálnom zariadení.

Ako pridať Face ID a Touch ID do .NET MAUI aplikácie

Postup je v .NET MAUI 10 rovnaký ako v natívnych projektoch: pridáte platform-specific implementáciu za rozhranie, injektujete ju cez MauiProgram a v ViewModeli ju volanie zabalíte do try/catch nad známe biometrické chyby. Rozdiel oproti natívnym projektom je len v tom, že XAML aplikácia beží v jednom procese so UIApplication, respektíve Activity, takže musíte prompt vyvolať na UI vlákne. Inak Android hodí IllegalStateException a iOS zlyhá tichým LAErrorAppCancel.

V praxi vytvoríte tri veci: rozhranie IBiometricAuthService v projekte MyApp, jednu implementáciu v Platforms/iOS a jednu v Platforms/Android. Nič v Microsoft.Maui.Essentials zatiaľ nedokáže vyvolať Face ID s CryptoObject, takže sa nedá vyhnúť tomu, aby ste sa dotkli LocalAuthentication a androidx.biometric priamo. Pozitívum: obe API sú stabilné roky, dobre zdokumentované a majú predvídateľné error kódy.

Prečo natívne API namiesto univerzálneho pluginu

Plugin.Fingerprint od Sven-Michaela Stübeho bol počas Xamarin.Forms éry defaultná voľba a stále má viac než 3 milióny stiahnutí. V MAUI však narazíte na tri konkrétne obmedzenia. Po prvé, plugin abstrahuje biometrický prompt cez IsBiometricEnrolledAsync, ale neexponuje BiometricPrompt.CryptoObject. Bez neho nemôžete Android Keystore kľúč viazať na biometriu tak, aby operačný systém odmietol odšifrovať dáta, keď používateľ pridá nový odtlačok. Práve to je požiadavka BankID, PSD2 aj mnohých enterprise SSO scenárov.

Po druhé, plugin používa LAPolicy.DeviceOwnerAuthentication na iOS, ktorá automaticky spadne na device passcode. To znie ako priateľské UX, ale znamená, že v revíznom procese App Store po vás recenzent žiada dôkaz, prečo v aplikácii vôbec biometriu potrebujete. Ak chcete LAPolicy.DeviceOwnerAuthenticationWithBiometrics (čisto biometria, bez fallbacku na PIN), musíte to zavolať priamo. Po tretie, plugin v3.x používa starú FingerprintManager API na Androide pod min-SDK 23, ktoré Google oficiálne označil za deprecated v prospech androidx.biometric. Nová Play Console pri submitoch pod target SDK 34 kontroluje, či aplikácia neuvádza použitie zastaraných tried v manifeste, a vyhadzuje varovanie „Deprecated biometric APIs detected".

Nechcem povedať, že plugin je zlý. Na jednoduchý „potvrď platbu odtlačkom prsta" scenár úplne stačí. Ale ak píšete čokoľvek, čo drží tokeny, kľúče alebo prístup k platobným metódam, budete Plugin.Fingerprint tak či tak obchádzať a písať native code, aby ste sa dostali k CryptoObject. Pointa článku je preskočiť plugin a rovno postaviť čistú vrstvu, ktorá vám povie pravdu o tom, čo sa v natívnych rámcoch deje.

iOS: LocalAuthentication, LAContext a Info.plist

Framework LocalAuthentication je súčasť iOS od verzie 8. V roku 2026 sa proti verzii z 2015 zmenili tri veci: pridal sa Optic ID pre Vision Pro (ak by ste náhodou robili visionOS build), evaluatedPolicyDomainState je stabilnejší a Apple presadzuje explicitné popisy použitia. Prvý krok je pridanie NSFaceIDUsageDescription do Platforms/iOS/Info.plist. Ak reťazec chýba a aplikácia zavolá EvaluatePolicy, systém tichým spôsobom zamietne prompt s LAErrorPasscodeNotSet, čo je dosť matúce, keďže kód je zavádzajúci a skutočná príčina je chýbajúci string.

<key>NSFaceIDUsageDescription</key>
<string>Použije sa na rýchle prihlásenie do vášho účtu.</string>

Samotná iOS implementácia služby vyzerá takto. Pozor na to, že LAContext musí byť vytvorený nanovo pre každý pokus. Reuse tej istej inštancie vedie k LAErrorInvalidContext na iOS 17 a novšom (to som si vylámal zuby, kým som prišiel na to, prečo mi druhý prompt v rade padá).

// Platforms/iOS/BiometricAuthService.cs
using LocalAuthentication;
using Foundation;

namespace MyApp.Services;

public class BiometricAuthService : IBiometricAuthService
{
    public Task<BiometricResult> AuthenticateAsync(string reason, CancellationToken ct = default)
    {
        var tcs = new TaskCompletionSource<BiometricResult>();
        var context = new LAContext { LocalizedFallbackTitle = string.Empty };

        // DeviceOwnerAuthenticationWithBiometrics = čistá biometria, bez PIN fallbacku.
        // Ak chcete povoliť aj passcode, použite DeviceOwnerAuthentication.
        if (!context.CanEvaluatePolicy(LAPolicy.DeviceOwnerAuthenticationWithBiometrics, out var canErr))
        {
            var status = (LAStatus)(int)canErr.Code;
            return Task.FromResult(new BiometricResult(false, MapError(status)));
        }

        context.EvaluatePolicy(
            LAPolicy.DeviceOwnerAuthenticationWithBiometrics,
            reason,
            (success, err) =>
            {
                if (success)
                    tcs.TrySetResult(new BiometricResult(true, BiometricError.None));
                else
                    tcs.TrySetResult(new BiometricResult(false, MapError((LAStatus)(int)(err?.Code ?? 0))));
            });

        ct.Register(() =>
        {
            context.Invalidate();
            tcs.TrySetResult(new BiometricResult(false, BiometricError.Canceled));
        });

        return tcs.Task;
    }

    static BiometricError MapError(LAStatus s) => s switch
    {
        LAStatus.UserCancel or LAStatus.AppCancel or LAStatus.SystemCancel => BiometricError.Canceled,
        LAStatus.AuthenticationFailed => BiometricError.Failed,
        LAStatus.BiometryNotAvailable => BiometricError.NotAvailable,
        LAStatus.BiometryNotEnrolled => BiometricError.NotEnrolled,
        LAStatus.BiometryLockout => BiometricError.LockedOut,
        LAStatus.PasscodeNotSet => BiometricError.NoPasscode,
        _ => BiometricError.Unknown,
    };
}

Detail, ktorý väčšina tutoriálov preskočí: LocalizedFallbackTitle nastavený na prázdny reťazec skryje tlačidlo „Enter Password" v prompte. Ak ho nenastavíte, iOS zobrazí fallback po prvom zlyhaní a používateľ sa vám môže dostať do accountu cez device passcode, čo v prípade bankových apiek nechcete. Podrobnosti sú v oficiálnej dokumentácii LocalAuthentication.

Android: androidx.biometric a BiometricPrompt

Na Androide je situácia komplikovanejšia, pretože AndroidX Biometric wrapper zjednocuje tri generácie natívnych API: FingerprintManager (API 23–27), BiometricPrompt v android.hardware (API 28+) a CredentialManager pre passkeys (API 34+). MAUI aplikácia s target SDK 34 by mala používať výhradne androidx.biometric.BiometricPrompt, ktorý pod pokrievkou routuje na správnu implementáciu podľa verzie Androidu.

Prvý krok je pridanie závislosti do MyApp.csproj pod ItemGroup s cieľom net10.0-android:

<ItemGroup Condition="$(TargetFramework.Contains('-android'))">
    <PackageReference Include="Xamarin.AndroidX.Biometric" Version="1.2.0.5" />
</ItemGroup>

V AndroidManifest.xml pridáte oprávnenie. USE_BIOMETRIC nahrádza staré USE_FINGERPRINT od API 28. Google Play od 2024 varuje submity, ktoré ešte deklarujú USE_FINGERPRINT, aj keď target SDK je 33+.

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

Android implementácia potrebuje aktivitu s FragmentActivity ako predka. V .NET MAUI je MainActivity už dediená z MauiAppCompatActivity, ktorá to spĺňa. Prompt teda vieme vyvolať priamo:

// Platforms/Android/BiometricAuthService.cs
using AndroidX.Biometric;
using AndroidX.Fragment.App;
using Java.Util.Concurrent;
using Microsoft.Maui.ApplicationModel;

namespace MyApp.Services;

public class BiometricAuthService : IBiometricAuthService
{
    public Task<BiometricResult> AuthenticateAsync(string reason, CancellationToken ct = default)
    {
        var tcs = new TaskCompletionSource<BiometricResult>();
        var activity = Platform.CurrentActivity as FragmentActivity
            ?? throw new InvalidOperationException("Aktuálna aktivita nie je FragmentActivity.");

        var executor = ContextCompat.GetMainExecutor(activity);
        var callback = new AuthCallback(tcs);
        var prompt = new BiometricPrompt(activity, executor, callback);

        var info = new BiometricPrompt.PromptInfo.Builder()
            .SetTitle("Prihlásenie")
            .SetSubtitle(reason)
            .SetAllowedAuthenticators(BiometricManager.Authenticators.BiometricStrong)
            .SetNegativeButtonText("Zrušiť")
            .Build();

        MainThread.BeginInvokeOnMainThread(() => prompt.Authenticate(info));

        ct.Register(() => prompt.CancelAuthentication());
        return tcs.Task;
    }

    private class AuthCallback : BiometricPrompt.AuthenticationCallback
    {
        private readonly TaskCompletionSource<BiometricResult> _tcs;
        public AuthCallback(TaskCompletionSource<BiometricResult> tcs) => _tcs = tcs;

        public override void OnAuthenticationSucceeded(BiometricPrompt.AuthenticationResult result)
            => _tcs.TrySetResult(new BiometricResult(true, BiometricError.None));

        public override void OnAuthenticationError(int errorCode, Java.Lang.ICharSequence errString)
            => _tcs.TrySetResult(new BiometricResult(false, MapError(errorCode)));

        public override void OnAuthenticationFailed()
        {
            // Voláme opakovane pri každom neúspešnom pokuse, nevraciame ešte výsledok.
        }

        static BiometricError MapError(int code) => code switch
        {
            BiometricPrompt.ErrorUserCanceled or BiometricPrompt.ErrorNegativeButton => BiometricError.Canceled,
            BiometricPrompt.ErrorNoBiometrics => BiometricError.NotEnrolled,
            BiometricPrompt.ErrorHwNotPresent or BiometricPrompt.ErrorHwUnavailable => BiometricError.NotAvailable,
            BiometricPrompt.ErrorLockout or BiometricPrompt.ErrorLockoutPermanent => BiometricError.LockedOut,
            _ => BiometricError.Unknown,
        };
    }
}

Unifikovaná služba IBiometricAuthService

V zdielanom projekte definujete rozhranie a záznamový typ, ktorý zapuzdrí výsledok. Používam record namiesto klasickej triedy, pretože chcem hodnotovú sémantiku pre testy a snapshot logging.

// MyApp/Services/IBiometricAuthService.cs
public interface IBiometricAuthService
{
    Task<BiometricResult> AuthenticateAsync(string reason, CancellationToken ct = default);
    Task<BiometricAvailability> GetAvailabilityAsync();
}

public record BiometricResult(bool IsAuthenticated, BiometricError Error);

public enum BiometricError
{
    None, Canceled, Failed, NotAvailable,
    NotEnrolled, LockedOut, NoPasscode, Unknown
}

public enum BiometricAvailability { Available, NoHardware, NotEnrolled, Disabled }

Registrácia v MauiProgram.cs. Pozor, obe platform-specific implementácie majú rovnaké meno, takže DI kontajner dostane vždy tú správnu podľa cieľa buildu:

public static MauiApp CreateMauiApp()
{
    var builder = MauiApp.CreateBuilder();
    builder.UseMauiApp<App>();

#if IOS || ANDROID
    builder.Services.AddSingleton<IBiometricAuthService, BiometricAuthService>();
#else
    builder.Services.AddSingleton<IBiometricAuthService, NoopBiometricAuthService>();
#endif

    return builder.Build();
}

Do ViewModelu injektnete službu ako závislosť. Pri použití CommunityToolkit.Mvvm vyzerá volanie takto. Celkovú stavbu ViewModelov rozoberáme podrobne v článku MVVM architektúra v .NET MAUI.

[RelayCommand]
private async Task UnlockAsync()
{
    var result = await _biometrics.AuthenticateAsync("Prihlásenie do účtu");
    if (result.IsAuthenticated)
        await _navigation.GoToAsync("///home");
    else
        StatusMessage = MapErrorToMessage(result.Error);
}

Viazanie tokenov na Secure Enclave a StrongBox

Toto je časť, ktorú väčšina MAUI tutoriálov preskočí, a je pritom najdôležitejšia. Bežný „biometrický login" pracuje takto: token uložím do SecureStorage.SetAsync, pri štarte aplikácie zavolám biometrický prompt, a keď je úspešný, prečítam token a pošlem ho serveru. Problém: SecureStorage na Androide používa Keystore bez viazania na biometriu, takže ak si niekto rootnutý telefón obehne a token z Keystore vytiahne, biometrický prompt sa nikdy nezavolá.

Správny prístup je vygenerovať v Keystore, respektíve v Secure Enclave, kľúč označený ako setUserAuthenticationRequired(true), tento kľúč zabaliť do BiometricPrompt.CryptoObject a token šifrovať/dešifrovať výhradne cez neho. Operačný systém potom fyzicky odmietne prístup ku kľúču bez čerstvej biometrickej autentifikácie:

// Android: generovanie AES kľúča viazaného na biometriu
var spec = new KeyGenParameterSpec.Builder("token_key",
        KeyStorePurpose.Encrypt | KeyStorePurpose.Decrypt)
    .SetBlockModes(KeyProperties.BlockModeGcm)
    .SetEncryptionPaddings(KeyProperties.EncryptionPaddingNone)
    .SetUserAuthenticationRequired(true)
    .SetIsStrongBoxBacked(true)  // StrongBox na Pixel 3+ a väčšine Samsung S9+
    .Build();

var keyGen = KeyGenerator.GetInstance("AES", "AndroidKeyStore");
keyGen.Init(spec);
keyGen.GenerateKey();

Následne pri volaní prompt.Authenticate(info, cryptoObject) po úspešnom prompt-e dostanete cipher, ktorým môžete dešifrovať uložený token. Toto isté sa dá na iOS docieliť cez SecAccessControl s flagom kSecAccessControlBiometryCurrentSet, ktorý navyše zaručí, že pridanie nového odtlačku prsta zneplatní existujúci kľúč. Do detailu HttpClient integrácie sa dostaneme v článku REST API v .NET MAUI, kde je popísaná servisná vrstva, do ktorej sa takto dešifrovaný token dá vstreknúť ako DelegatingHandler.

Ošetrenie chýb a fallback na PIN alebo heslo

Biometrické API majú notoricky nespoľahlivé error kódy. Na iOS LAErrorAuthenticationFailed príde po troch neúspešných pokusoch, ale LAErrorBiometryLockout až po piatich. A pri lockout-e sa Face ID/Touch ID odblokujú až po zadaní device passcode v systémovej dialógovej obrazovke, ktorú vaša aplikácia nedokáže vyvolať. Používateľ musí ísť sám do Settings.

Odporúčaný fallback flow, ktorý pravidelne používam v produkčných apkách:

ChybaOdporúčaná akciaUX text
NotAvailableSkryť biometrickú voľbu, ísť rovno na heslo„Zariadenie nepodporuje biometriu."
NotEnrolledPonúknuť link do systémových nastavení„Nemáte nastavené žiadne odtlačky/Face ID."
CanceledTicho, používateľ vie, čo urobil(žiadny toast)
FailedZobraziť retry tlačidlo, počítať pokusy„Nepodarilo sa overiť. Skúste znova."
LockedOutVypnúť biometriu na 30 s, ponúknuť heslo„Príliš veľa pokusov, prihláste sa heslom."
NoPasscodeUvedomiť si, že token bol zneplatnený„Prihláste sa znova heslom."

Ako testovať biometrickú autentifikáciu na simulátore

Testovanie biometrie v xUnit je poloviaty príbeh, pretože samotný prompt sa nedá spustiť headless. Rozdeľte testy do troch úrovní. Jednotkové testy pokrývajú mapovanie error kódov a stavovú logiku ViewModelu s mock-nutým IBiometricAuthService. Integračné testy bežia na iOS Simulator, respektíve Android emulator, s ovládaním prompt-u cez CLI: xcrun simctl ui booted enroll_biometrics enrolled=true pre iOS, respektíve adb -e emu finger touch 1 pre Android emulator (Google API 34+).

End-to-end testy s Appium 2.x podporujú biometriu cez mobile: sendBiometricMatch na iOS a mobile: fingerprint na Androide. Poznámka z praxe: emulátor spoľahlivo emuluje happy path, ale nedokáže simulovať StrongBox. Ak generujete kľúč s setIsStrongBoxBacked(true), na emulátore dostanete StrongBoxUnavailableException. Kód preto vždy oblečte v try/catch a fallbacknite na non-StrongBox variant. Príklad plnej testovacej infraštruktúry si môžete odvodiť z prístupu k skryptom, ktoré popisuje článok CI/CD pre .NET MAUI v GitHub Actions. Biometrické testy tam bežia v macOS runneri s bootnutým iPhone 16 simulátorom.

Manuálny checklist pred App Store submitom, ktorý používam:

  1. Overiť, že NSFaceIDUsageDescription je v Info.plist a text vysvetľuje, prečo aplikácia Face ID používa (App Review requirement).
  2. Vypnúť Face ID/Touch ID v Settings, potom skontrolovať, že aplikácia zobrazí správnu chybovú hlášku, nie crash.
  3. Odstrániť všetky enrollnuté odtlačky/tvár, overiť NotEnrolled flow.
  4. 5x neúspešný pokus, overiť LockedOut flow a to, že app zablokuje ďalšie pokusy na 30 s.
  5. Pridať nový odtlačok/tvár v Settings, overiť, že kľúč v Keystore/Keychain sa zneplatnil (dešifrovanie vyhodí InvalidKeyException alebo errSecUserAuthenticationRequired).
  6. Prepnúť do lietadlového režimu, overiť, že sa Face ID prompt zavolá aj offline (nezávisí od siete).

Ak niektorý z týchto krokov app neprežije, budete mať problém buď pri App Store recenzii, alebo v produkcii pri prvom reset-e zariadenia. Google Play navyše od 2025 vyžaduje, aby aplikácie deklarujúce USE_BIOMETRIC v Data Safety formulári uviedli, že biometrické dáta „nikdy neopúšťajú zariadenie", inak automatizované review upload vráti späť.

Často kladené otázky

Dá sa Face ID použiť v .NET MAUI aplikácii bez pluginu?

Áno. Framework LocalAuthentication je dostupný cez binding Microsoft.iOS, ktorý je súčasťou net10.0-ios workload-u. Stačí pridať platform-specific implementáciu do Platforms/iOS/ a zavolať LAContext.EvaluatePolicy priamo, žiadny NuGet balík nie je potrebný.

Prečo Face ID prompt v mojej MAUI aplikácii nikdy nezobrazí?

V 90 % prípadov chýba NSFaceIDUsageDescription v Info.plist. iOS v takom prípade tichým spôsobom zamietne volanie s chybou LAErrorPasscodeNotSet, čo je zavádzajúci kód. Pridajte string, buildnite znovu a problém zmizne.

Je biometrická autentifikácia bezpečná pre bankové aplikácie?

Áno, ak správne viažete tokeny na Secure Enclave (iOS) alebo StrongBox (Android) cez CryptoObject. Bez tohto viazania je „biometria" len UX vrstva, token vo SecureStorage sa dá získať aj bez prompt-u. Regulácie ako PSD2 SCA priamo vyžadujú kryptografické viazanie.

Aký je rozdiel medzi BiometricStrong a BiometricWeak na Androide?

BiometricStrong obsahuje 3D face unlock (Pixel, Galaxy S24+), Touch ID a certifikované fingerprint senzory. BiometricWeak zahŕňa aj 2D face unlock založený len na kamere. STRONG je jediný autentikátor, s ktorým môžete používať CryptoObject a viazať kľúče v Keystore.

Ako otestovať Face ID v iOS simulátore?

V bežiacom simulátore z menu vyberte Features → Face ID → Enrolled, a následne Features → Face ID → Matching Face (respektíve Non-matching Face). CLI ekvivalent je xcrun simctl ui booted enroll_biometrics enrolled=true a xcrun simctl ui booted send_biometric_match. Pracuje spoľahlivo od Xcode 15.

Funguje Plugin.Fingerprint stále v .NET MAUI 10?

Áno, verzia 3.0.0 podporuje .NET 8 aj .NET 10. Ale API neexponuje BiometricPrompt.CryptoObject, teda nemôžete kryptograficky viazať kľúče v Keystore. Pre jednoduché „potvrď akciu odtlačkom" stačí, pre bankové alebo fintech scenáre napíšte natívnu implementáciu podľa vzoru v tomto článku.

David O'Reilly
O Autorovi David O'Reilly

Native iOS/Android specialist turned MAUI advocate. Writes about the gritty platform details most cross-platform tutorials skip.