Hvorfor sikkerhed ikke er valgfrit i mobilapps
Mobilapps håndterer følsomme data. Tokens, adgangskoder, personlige oplysninger, betalingsdata — listen er lang. Og konsekvenserne af et sikkerhedsbrud? De kan være ødelæggende for både virksomheden og brugerne.
Alligevel ser jeg gang på gang, at sikkerhed bliver nedprioriteret til fordel for den næste feature. Det er en dyr fejl at begå.
I .NET MAUI har du faktisk adgang til et ret solidt sæt sikkerhedsværktøjer direkte fra frameworket, plus et rigt økosystem af NuGet-pakker til mere avancerede scenarier. Problemet er bare, at dokumentationen er spredt ud over utallige sider, og det kan være frustrerende at prøve at danne sig et samlet overblik.
Så denne guide samler det hele ét sted. Vi dækker SecureStorage til sikker lokal lagring, biometrisk autentificering med fingeraftryk og ansigtsgenkendelse, OAuth 2.0 med MSAL til sikker login via identitetsudbydere, og certificate pinning til at beskytte netværkskommunikation. Alt med fungerende kodeeksempler til .NET MAUI og .NET 10.
SecureStorage: Sikker lokal lagring af følsomme data
Den mest grundlæggende sikkerhedsmekanisme i din .NET MAUI-app er SecureStorage. Det er et simpelt nøgle-/værdi-lager, der automatisk krypterer dine data med platformens native sikkerhedsmekanismer. Simpelt, men effektivt.
Sådan fungerer krypteringen per platform
Det er værd at forstå, hvad der foregår under motorhjelmen — det giver en langt bedre fornemmelse for styrker og begrænsninger:
- Android: Bruger
EncryptedSharedPreferencesfra Android Security-biblioteket. Nøgler krypteres deterministisk, og værdier krypteres med AES-256 GCM. Det er industristandard, og det virker. - iOS/macOS: Bruger Keychain, Apples sikre nøglering. En ting man skal være opmærksom på: værdier kan synkroniseres via iCloud, så de kan faktisk overleve en afinstallation af appen.
- Windows: Bruger
DataProtectionProvidermed kryptering bundet til den aktuelle brugerkonto.
Grundlæggende brug af SecureStorage
API'et er bevidst holdt simpelt — gem, hent og slet:
// Gem en OAuth-token sikkert
await SecureStorage.Default.SetAsync("access_token", tokenValue);
// Hent token igen
string? token = await SecureStorage.Default.GetAsync("access_token");
if (token is null)
{
// Ingen token fundet — brugeren skal logge ind igen
await NavigerTilLogin();
}
// Fjern en specifik nøgle
SecureStorage.Default.Remove("access_token");
// Fjern alt (f.eks. ved logout)
SecureStorage.Default.RemoveAll();
Fejlhåndtering er kritisk
Her er et punkt, som overraskende mange overser: SecureStorage kan kaste undtagelser. Krypteringsnøgler kan ændre sig (typisk efter en OS-opdatering), og data kan blive korrupt. Du skal håndtere dette, ellers risikerer du at din app crasher for brugere, der har opdateret deres telefon.
public class SikkerTokenService
{
public async Task<string?> HentTokenAsync(string nøgle)
{
try
{
return await SecureStorage.Default.GetAsync(nøgle);
}
catch (Exception ex)
{
// Logning af fejl
System.Diagnostics.Debug.WriteLine(
$"SecureStorage fejl for nøgle '{nøgle}': {ex.Message}");
// Fjern den korrupte værdi og returner null
SecureStorage.Default.Remove(nøgle);
return null;
}
}
public async Task GemTokenAsync(string nøgle, string værdi)
{
try
{
await SecureStorage.Default.SetAsync(nøgle, værdi);
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine(
$"Kunne ikke gemme i SecureStorage: {ex.Message}");
throw;
}
}
}
Android-specifik konfiguration: Auto Backup
På Android er der en vigtig faldgrube, jeg gerne vil fremhæve. Auto Backup (fra Android 6.0+) sikkerhedskopierer automatisk app-data, inklusive SharedPreferences. Men her er problemet: de krypterede værdier kan ikke dekrypteres efter en gendannelse, fordi krypteringsnøglen er bundet til den oprindelige enhed.
.NET MAUI håndterer det automatisk ved at fjerne nøglen, men du bør eksplicit konfigurere backup-regler i AndroidManifest.xml:
<application android:allowBackup="true"
android:fullBackupContent="@xml/auto_backup_rules">
</application>
Og opret filen Platforms/Android/Resources/xml/auto_backup_rules.xml:
<?xml version="1.0" encoding="utf-8"?>
<full-backup-content>
<include domain="sharedpref" path="." />
<exclude domain="sharedpref"
path="${applicationId}.microsoft.maui.essentials.preferences.xml" />
</full-backup-content>
Biometrisk autentificering: Fingeraftryk og ansigtsgenkendelse
Biometrisk login er ikke bare en nice-to-have-feature længere — det er en forventning fra brugerne. Folk vil ikke taste adgangskoder, og ærligt talt, det forstår jeg godt. Biometri giver en hurtig og sikker autentificeringsoplevelse.
I .NET MAUI implementerer vi dette via platform-specifikke API'er, pakket ind i en ren abstraktion.
Arkitektur: Interface-baseret abstraktion
Vi starter med en fælles interface, som vores ViewModels kan bruge uanset platform:
public interface IBiometriService
{
Task<bool> ErTilgængeligAsync();
Task<BiometriResultat> AutentificerAsync(string årsag);
}
public record BiometriResultat(
bool Succes,
string? Fejlbesked = null
);
iOS-implementering med LocalAuthentication
På iOS bruger vi LAContext fra LocalAuthentication-frameworket. Det understøtter både Touch ID og Face ID, og implementeringen er heldigvis ret ligetil:
// Platforms/iOS/Services/IosBiometriService.cs
using Foundation;
using LocalAuthentication;
public class IosBiometriService : IBiometriService
{
public Task<bool> ErTilgængeligAsync()
{
var context = new LAContext();
return Task.FromResult(
context.CanEvaluatePolicy(
LAPolicy.DeviceOwnerAuthenticationWithBiometrics,
out _));
}
public async Task<BiometriResultat> AutentificerAsync(string årsag)
{
var context = new LAContext();
// Deaktiver fallback-knap til adgangskode
context.LocalizedFallbackTitle = "";
var (succes, fejl) = await context.EvaluatePolicyAsync(
LAPolicy.DeviceOwnerAuthenticationWithBiometrics,
årsag);
if (succes)
return new BiometriResultat(true);
var fejlKode = (LAStatus)(int)fejl!.Code;
string besked = fejlKode switch
{
LAStatus.UserCancel => "Brugeren annullerede",
LAStatus.UserFallback => "Brugeren valgte alternativ metode",
LAStatus.BiometryNotAvailable => "Biometri er ikke tilgængelig",
LAStatus.BiometryNotEnrolled => "Ingen biometri registreret",
LAStatus.BiometryLockout => "Biometri er låst — for mange forsøg",
_ => $"Ukendt fejl: {fejlKode}"
};
return new BiometriResultat(false, besked);
}
}
Vigtigt: For Face ID skal du tilføje en NSFaceIDUsageDescription i din Info.plist. Glemmer du det, crasher appen ved første forsøg — uden forklaring:
<key>NSFaceIDUsageDescription</key>
<string>Vi bruger Face ID til at beskytte din konto</string>
Android-implementering med BiometricPrompt
På Android bruger vi AndroidX.Biometric.BiometricPrompt, som er den anbefalede API fra Android 9 og frem. Det kræver lidt mere kode end iOS, men resultatet er det samme:
// Platforms/Android/Services/AndroidBiometriService.cs
using AndroidX.Biometric;
using AndroidX.Core.Content;
using AndroidX.Fragment.App;
public class AndroidBiometriService : IBiometriService
{
public Task<bool> ErTilgængeligAsync()
{
var context = Platform.CurrentActivity!;
var manager = BiometricManager.From(context);
var resultat = manager.CanAuthenticate(
BiometricManager.Authenticators.BiometricStrong);
return Task.FromResult(
resultat == BiometricManager.BiometricSuccess);
}
public Task<BiometriResultat> AutentificerAsync(string årsag)
{
var tcs = new TaskCompletionSource<BiometriResultat>();
var activity = Platform.CurrentActivity as FragmentActivity;
if (activity is null)
{
tcs.SetResult(new BiometriResultat(false, "Ingen aktiv Activity"));
return tcs.Task;
}
var executor = ContextCompat.GetMainExecutor(activity);
var callback = new BiometriCallback(tcs);
var prompt = new BiometricPrompt(activity, executor, callback);
var promptInfo = new BiometricPrompt.PromptInfo.Builder()
.SetTitle("Biometrisk login")
.SetSubtitle(årsag)
.SetNegativeButtonText("Annuller")
.SetAllowedAuthenticators(
BiometricManager.Authenticators.BiometricStrong)
.Build();
MainThread.BeginInvokeOnMainThread(() => prompt.Authenticate(promptInfo));
return tcs.Task;
}
private class BiometriCallback : BiometricPrompt.AuthenticationCallback
{
private readonly TaskCompletionSource<BiometriResultat> _tcs;
public BiometriCallback(
TaskCompletionSource<BiometriResultat> tcs) => _tcs = tcs;
public override void OnAuthenticationSucceeded(
BiometricPrompt.AuthenticationResult result)
=> _tcs.SetResult(new BiometriResultat(true));
public override void OnAuthenticationError(
int errorCode, Java.Lang.ICharSequence errString)
=> _tcs.SetResult(
new BiometriResultat(false, errString?.ToString()));
public override void OnAuthenticationFailed()
{
// Kaldes ved hvert mislykket forsøg — prompten forbliver åben
// Vi logger det, men lader BiometricPrompt håndtere retries
}
}
}
Registrér services med Dependency Injection
Nu kobler vi det hele sammen i MauiProgram.cs. Conditional compilation sørger for at den rigtige service bruges per platform:
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder.UseMauiApp<App>();
#if ANDROID
builder.Services.AddSingleton<IBiometriService, AndroidBiometriService>();
#elif IOS
builder.Services.AddSingleton<IBiometriService, IosBiometriService>();
#endif
builder.Services.AddSingleton<SikkerTokenService>();
builder.Services.AddTransient<LoginViewModel>();
return builder.Build();
}
Brug i en LoginViewModel
Med alt sat op er selve login-flowet faktisk ret elegant:
public partial class LoginViewModel : ObservableObject
{
private readonly IBiometriService _biometri;
private readonly SikkerTokenService _tokenService;
public LoginViewModel(
IBiometriService biometri,
SikkerTokenService tokenService)
{
_biometri = biometri;
_tokenService = tokenService;
}
[ObservableProperty]
private bool _biometriTilgængelig;
[ObservableProperty]
private string? _fejlBesked;
public async Task InitialiserAsync()
{
BiometriTilgængelig = await _biometri.ErTilgængeligAsync();
}
[RelayCommand]
private async Task BiometriskLoginAsync()
{
var resultat = await _biometri.AutentificerAsync(
"Log ind med biometri");
if (resultat.Succes)
{
var token = await _tokenService.HentTokenAsync("access_token");
if (token is not null)
{
// Token fundet — naviger til hovedsiden
await Shell.Current.GoToAsync("//hjem");
}
else
{
// Ingen gemt token — kræv fuld login
FejlBesked = "Sessionen er udløbet. Log venligst ind igen.";
}
}
else
{
FejlBesked = resultat.Fejlbesked;
}
}
}
OAuth 2.0 og OpenID Connect med MSAL
Til produktionsapps har du brug for en robust autentificeringsløsning med en ekstern identitetsudbyder. Det er her Microsoft Authentication Library (MSAL) kommer ind i billedet. Det er det anbefalede valg til .NET MAUI-apps, der arbejder med Microsoft Entra ID (det der tidligere hed Azure AD), men principperne gælder for enhver OAuth 2.0-udbyder.
Installation og konfiguration
Start med at tilføje MSAL NuGet-pakken:
dotnet add package Microsoft.Identity.Client
Opret derefter en konfigurationsklasse til dine MSAL-indstillinger:
public static class AuthKonfiguration
{
public const string ClientId = "din-client-id-fra-entra";
public const string TenantId = "din-tenant-id";
// Redirect URI varierer per platform
public static string RedirectUri =>
DeviceInfo.Platform == DevicePlatform.Android
? $"msal{ClientId}://auth"
: $"msal{ClientId}://auth";
public static readonly string[] Scopes =
{
"openid",
"profile",
"email",
"offline_access",
"api://din-api-id/access_as_user"
};
public static string Authority =>
$"https://login.microsoftonline.com/{TenantId}/v2.0";
}
Autentificeringsservice med MSAL
Lad os indkapsle MSAL-logikken i en service, der håndterer både interaktiv login, silent token refresh og logout. Det er her det meste af magien sker:
public class MsalAuthService
{
private IPublicClientApplication? _authClient;
private readonly SikkerTokenService _tokenService;
public MsalAuthService(SikkerTokenService tokenService)
{
_tokenService = tokenService;
}
private IPublicClientApplication AuthClient =>
_authClient ??= PublicClientApplicationBuilder
.Create(AuthKonfiguration.ClientId)
.WithAuthority(AuthKonfiguration.Authority)
.WithRedirectUri(AuthKonfiguration.RedirectUri)
#if IOS
.WithIosKeychainSecurityGroup("com.microsoft.adalcache")
#endif
.Build();
public async Task<AuthResultat> LoginAsync()
{
try
{
// Forsøg silent login først (cached token)
var konti = await AuthClient.GetAccountsAsync();
var konto = konti.FirstOrDefault();
if (konto is not null)
{
var silentResult = await AuthClient
.AcquireTokenSilent(
AuthKonfiguration.Scopes, konto)
.ExecuteAsync();
return new AuthResultat(
true,
silentResult.AccessToken,
silentResult.ExpiresOn);
}
}
catch (MsalUiRequiredException)
{
// Silent login fejlede — kræver interaktiv login
}
try
{
// Interaktiv login med system browser
var interaktivResult = await AuthClient
.AcquireTokenInteractive(AuthKonfiguration.Scopes)
#if ANDROID
.WithParentActivityOrWindow(Platform.CurrentActivity)
#endif
.WithUseEmbeddedWebView(false)
.ExecuteAsync();
// Gem token sikkert
await _tokenService.GemTokenAsync(
"access_token",
interaktivResult.AccessToken);
return new AuthResultat(
true,
interaktivResult.AccessToken,
interaktivResult.ExpiresOn);
}
catch (MsalClientException ex) when (ex.ErrorCode == "authentication_canceled")
{
return new AuthResultat(false, Fejl: "Login annulleret");
}
catch (Exception ex)
{
return new AuthResultat(false, Fejl: $"Login fejlede: {ex.Message}");
}
}
public async Task LogUdAsync()
{
var konti = await AuthClient.GetAccountsAsync();
foreach (var konto in konti)
{
await AuthClient.RemoveAsync(konto);
}
SecureStorage.Default.RemoveAll();
}
}
public record AuthResultat(
bool Succes,
string? AccessToken = null,
DateTimeOffset? UdløberKl = null,
string? Fejl = null
);
Platformspecifik konfiguration
MSAL kræver platformspecifik opsætning for at redirect URI'er fungerer korrekt. Det her er den del, der kan give grå hår, hvis du glemmer det.
Android — AndroidManifest.xml:
<activity
android:name="microsoft.identity.client.BrowserTabActivity"
android:configChanges="orientation|screenSize"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="msalDIN-CLIENT-ID"
android:host="auth" />
</intent-filter>
</activity>
iOS — Info.plist:
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>msalDIN-CLIENT-ID</string>
</array>
</dict>
</array>
Certificate Pinning: Beskyt din netværkskommunikation
HTTPS er et absolut minimum, men det er ærligt talt ikke nok i sig selv. Et man-in-the-middle-angreb kan stadig ske, hvis angriberens certifikat er betroet af enheden — for eksempel via en kompromitteret CA. Certificate pinning løser dette ved kun at acceptere specifikke, kendte certifikater.
Implementering med HttpMessageHandler
public class CertificatePinningHandler : HttpClientHandler
{
// SHA-256 hash af dit servers certifikats public key
private static readonly string[] TilladeFingeraftryk =
{
"sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
// Tilføj backup-pin for certifikatrotation
"sha256/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB="
};
public CertificatePinningHandler()
{
ServerCertificateCustomValidationCallback =
(message, cert, chain, errors) =>
{
if (cert is null) return false;
// Beregn SHA-256 hash af public key
var publicKey = cert.GetPublicKey();
using var sha256 = System.Security.Cryptography.SHA256.Create();
var hash = sha256.ComputeHash(publicKey);
var pin = $"sha256/{Convert.ToBase64String(hash)}";
return TilladeFingeraftryk.Contains(pin);
};
}
}
// Registrér i DI
builder.Services.AddHttpClient("SikkerApi", client =>
{
client.BaseAddress = new Uri("https://api.ditdomæne.dk");
})
.ConfigurePrimaryHttpMessageHandler<CertificatePinningHandler>();
En vigtig detalje: Inkludér altid en backup-pin. Hvis dit primære certifikat udløber, og du kun har pinnet det ene, så kan dine brugere pludselig ikke forbinde — og du kan næppe opdatere appen hurtigt nok via app stores. Det er en af de mest smertefulde fejl inden for mobil sikkerhed, og jeg har set det ske i praksis.
Sikkerhedstjekliste: Best Practices for .NET MAUI
Udover de specifikke implementeringer ovenfor er her en samlet tjekliste, du bør have hængende over dit skrivebord (eller i det mindste i dine bookmarks):
- Gem aldrig hemmeligheder i koden. API-nøgler, client secrets og lignende hører hjemme i en sikker backend — aldrig i appen. Dekompilering af en .NET MAUI-app er trivielt.
- Brug altid HTTPS. Konfigurér App Transport Security (iOS) og Network Security Config (Android) til at kræve sikre forbindelser.
- Validér input på serveren. Stol aldrig på klientsiden alene — al validering kan omgås.
- Aktivér NativeAOT eller fuld trimming for at gøre reverse engineering sværere. Det erstatter ikke obfuskering, men det hjælper bestemt.
- Implementér token-rotation. Brug refresh tokens med kort levetid for access tokens (typisk 5-15 minutter).
- Lås appen ved inaktivitet. Kræv biometrisk genautentificering efter en periode uden interaktion.
- Deaktivér screenshots på følsomme skærme (Android:
Window.SetFlags(WindowManagerFlags.Secure)). - Log aldrig følsomme data. Fjern alle
Debug.WriteLine-kald med tokens eller brugerdata i produktionsbuilds.
Komplet login-flow: Alt samlet
Okay, lad os se hvordan alle komponenterne spiller sammen i et realistisk login-flow. Det her er den del, hvor det hele giver mening:
public partial class LoginPage : ContentPage
{
private readonly LoginViewModel _viewModel;
public LoginPage(LoginViewModel viewModel)
{
InitializeComponent();
BindingContext = _viewModel = viewModel;
}
protected override async void OnAppearing()
{
base.OnAppearing();
await _viewModel.InitialiserAsync();
// Automatisk biometrisk login, hvis token findes
var token = await SecureStorage.Default.GetAsync("access_token");
if (token is not null && _viewModel.BiometriTilgængelig)
{
await _viewModel.BiometriskLoginCommand.ExecuteAsync(null);
}
}
}
Og her er XAML'en for login-siden:
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="MinApp.Views.LoginPage">
<VerticalStackLayout Spacing="20" Padding="30"
VerticalOptions="Center">
<Image Source="app_logo.png" HeightRequest="120"
HorizontalOptions="Center" />
<Label Text="Velkommen tilbage"
FontSize="28" FontAttributes="Bold"
HorizontalOptions="Center" />
<!-- OAuth login-knap -->
<Button Text="Log ind med Microsoft"
Command="{Binding OAuthLoginCommand}"
BackgroundColor="{StaticResource Primary}"
TextColor="White"
CornerRadius="8" />
<!-- Biometrisk login-knap (kun synlig hvis tilgængelig) -->
<ImageButton Source="fingerprint_icon.png"
Command="{Binding BiometriskLoginCommand}"
IsVisible="{Binding BiometriTilgængelig}"
HeightRequest="64" WidthRequest="64"
HorizontalOptions="Center" />
<!-- Fejlbesked -->
<Label Text="{Binding FejlBesked}"
TextColor="Red"
IsVisible="{Binding FejlBesked, Converter={StaticResource StringToBoolConverter}}"
HorizontalOptions="Center" />
</VerticalStackLayout>
</ContentPage>
Ofte stillede spørgsmål
Er SecureStorage sikkert nok til at gemme OAuth-tokens?
Ja, til de fleste scenarier er det helt fint. SecureStorage bruger platformens native sikkerhedsmekanismer (Keychain på iOS, EncryptedSharedPreferences på Android), som er designet specifikt til dette formål. Dog bør du aldrig gemme langlivede hemmeligheder som client secrets i appen — dem hører hjemme på din backend-server.
Hvad sker der, hvis brugeren ikke har konfigureret biometri?
Dit kald til ErTilgængeligAsync() returnerer false, og du bør falde tilbage til en alternativ autentificeringsmetode som adgangskode eller PIN. Test altid for tilgængelighed, før du forsøger biometrisk autentificering — og vis aldrig en biometrisk login-knap, hvis funktionen ikke er tilgængelig på enheden.
Kan jeg bruge andre OAuth-udbydere end Microsoft Entra ID?
Absolut. Selvom MSAL er designet til Microsoft-identiteter, kan du bruge biblioteker som IdentityModel.OidcClient til at implementere OAuth 2.0 / OpenID Connect med enhver udbyder — Auth0, Okta, Google, Firebase Auth, eller din egen IdentityServer-instans. Flowet er grundlæggende det samme: redirect til udbyderen, modtag en autorisationskode, og udveksle den til tokens.
Hvordan håndterer jeg certificate pinning ved certifikatfornyelse?
Inkludér altid mindst to pins: en for det aktuelle certifikat og en backup-pin til det næste planlagte certifikat. Når du fornyer certifikatet, opdaterer du appen med en ny backup-pin og bevarer den nuværende. Et alternativ er at pinne til root- eller intermediate-CA-certifikatet i stedet for leaf-certifikatet — det giver mere fleksibilitet ved fornyelse, men lidt mindre sikkerhed.
Skal jeg bruge System Browser eller Embedded WebView til OAuth?
Brug altid System Browser. Det er Microsofts anbefaling, og det er OAuth 2.0 Best Practice. Embedded WebViews giver appen teoretisk adgang til brugerens indtastede credentials, og det er en sikkerhedsrisiko du ikke vil tage. System Browser bruger enhedens standardbrowser, som allerede har brugerens sessioner (hvilket giver SSO-fordele) og er fuldstændig isoleret fra din app.