Why Mobile Security Demands a Different Mindset
If you've shipped a web app before and assumed the same security playbook would carry over to mobile — yeah, I had that same assumption once. It doesn't work that way. Mobile apps live in a fundamentally different threat landscape. Your code runs on devices you don't control, in environments you can't predict, connected to networks you absolutely can't trust.
Users jailbreak their phones. Attackers decompile your APK. Man-in-the-middle proxies intercept traffic on coffee shop Wi-Fi. The attack surface is enormous.
.NET MAUI gives you a powerful cross-platform framework, but security isn't something it hands you for free. You have to build it in deliberately — from how you authenticate users, to where you store tokens, to how you verify the server on the other end of an HTTPS connection. Get any of these wrong, and your app becomes a liability instead of an asset.
This guide walks through the full security stack for .NET MAUI applications. We'll cover OAuth 2.0 and OpenID Connect authentication flows, MSAL.NET integration with Microsoft Entra ID, social login with Google and Apple, biometric authentication, secure token storage, certificate pinning, and the defense-in-depth patterns that separate production-grade apps from weekend projects. Every section includes working code you can adapt to your own apps.
Understanding OAuth 2.0 and OIDC in the Mobile Context
Before we touch any code, let's establish why OAuth 2.0 with PKCE (Proof Key for Code Exchange) is the standard for mobile authentication — and why older approaches like the implicit flow or storing passwords locally are considered dangerous.
Mobile apps are classified as "public clients" in OAuth terminology. Unlike server-side applications, they can't securely store a client secret — anyone can decompile your app and extract it. The Authorization Code flow with PKCE solves this by adding a dynamically generated code verifier and challenge to each authentication request, making intercepted authorization codes useless to attackers.
Here's how the flow works in a .NET MAUI context:
- Your app generates a random
code_verifierand derives acode_challengefrom it using SHA-256. - The user is redirected to the identity provider's authorization endpoint in a system browser or embedded web view, along with the code challenge.
- After authentication, the identity provider redirects back to your app with an authorization code.
- Your app exchanges the authorization code (plus the original code verifier) for access and refresh tokens.
- The identity provider verifies the code verifier against the original challenge before issuing tokens.
This flow ensures that even if an attacker intercepts the authorization code (say, via a malicious app registered for the same custom URI scheme), they can't exchange it for tokens without the code verifier that only your app possesses.
Why System Browser Over Embedded WebView?
Microsoft and security experts strongly recommend using the system browser for authentication rather than embedding a WebView. And honestly, once you understand the reasoning, it's hard to argue otherwise.
The system browser shares cookies and session state across apps, enabling single sign-on. More importantly, it prevents the app from accessing the user's credentials — with an embedded WebView, you technically have access to everything the user types, which is both a liability and a trust issue. The system browser also shows the proper URL bar, letting users verify they're on the correct identity provider domain.
Implementing Authentication with MSAL.NET
MSAL.NET (Microsoft Authentication Library) is the official library for authenticating users against Microsoft Entra ID (formerly Azure AD), Azure AD B2C, and other Microsoft identity platforms. Since version 4.47.0, MSAL.NET has provided first-class .NET MAUI support, handling all the complexity of PKCE, token caching, silent token renewal, and platform-specific browser integration for you.
Setting Up MSAL.NET in Your MAUI Project
Start by installing the NuGet package:
dotnet add package Microsoft.Identity.Client
Next, configure your authentication service. The key here is building the IPublicClientApplication with the correct platform-specific redirect URIs:
using Microsoft.Identity.Client;
public class AuthService
{
private readonly IPublicClientApplication _authClient;
private const string ClientId = "your-client-id-here";
private const string TenantId = "your-tenant-id";
private const string Authority = $"https://login.microsoftonline.com/{TenantId}";
// Platform-specific redirect URIs
#if ANDROID
private const string RedirectUri = $"msal{ClientId}://auth";
#elif IOS
private const string RedirectUri = $"msal{ClientId}://auth";
#else
private const string RedirectUri = $"https://login.microsoftonline.com/common/oauth2/nativeclient";
#endif
private readonly string[] _scopes = { "openid", "profile", "email", "offline_access" };
public AuthService()
{
_authClient = PublicClientApplicationBuilder
.Create(ClientId)
.WithAuthority(Authority)
.WithRedirectUri(RedirectUri)
.WithIosKeychainSecurityGroup("com.yourcompany.yourapp")
.Build();
}
}
The Sign-In Flow: Silent First, Interactive as Fallback
The best practice with MSAL is to always try acquiring a token silently first. MSAL maintains an in-memory token cache and will return a cached token if it's still valid, or automatically refresh it using the refresh token. Only fall back to an interactive login when there's no cached account or the silent acquisition fails:
public async Task<AuthenticationResult?> SignInAsync()
{
try
{
// Try silent authentication first
var accounts = await _authClient.GetAccounts();
var firstAccount = accounts.FirstOrDefault();
if (firstAccount != null)
{
try
{
return await _authClient
.AcquireTokenSilent(_scopes, firstAccount)
.ExecuteAsync();
}
catch (MsalUiRequiredException)
{
// Token expired and refresh failed — need interactive login
}
}
// Fall back to interactive login
return await _authClient
.AcquireTokenInteractive(_scopes)
#if ANDROID
.WithParentActivityOrWindow(Platform.CurrentActivity)
#elif IOS
.WithSystemWebViewOptions(new SystemWebViewOptions
{
iOSHidePrivacyPrompt = true
})
#endif
.ExecuteAsync();
}
catch (MsalException ex)
{
System.Diagnostics.Debug.WriteLine($"MSAL Error: {ex.Message}");
return null;
}
}
public async Task SignOutAsync()
{
var accounts = await _authClient.GetAccounts();
foreach (var account in accounts)
{
await _authClient.RemoveAsync(account);
}
}
Platform-Specific Configuration
MSAL requires platform-specific setup to handle the authentication callback correctly. This is one of those things that's easy to miss during initial setup, so pay close attention here.
Android: In your AndroidManifest.xml, declare an activity to handle the MSAL redirect:
<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:host="auth"
android:scheme="msalyour-client-id" />
</intent-filter>
</activity>
You also need to forward the activity result in your MainActivity.cs:
protected override void OnActivityResult(int requestCode, Result resultCode, Intent? data)
{
base.OnActivityResult(requestCode, resultCode, data);
AuthenticationContinuationHelper.SetAuthenticationContinuationEventArgs(
requestCode, resultCode, data);
}
iOS: Add the URL scheme to your Info.plist and enable Keychain access in Entitlements.plist:
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>msalyour-client-id</string>
</array>
</dict>
</array>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)com.yourcompany.yourapp</string>
</array>
Social Login with WebAuthenticator
.NET MAUI provides a built-in WebAuthenticator API for implementing OAuth-based authentication with any provider — Google, Apple, Facebook, or your own custom identity server. This is particularly useful when you're not tied to the Microsoft ecosystem and need a more provider-agnostic approach.
Google Sign-In Implementation
Here's how to implement Google Sign-In using WebAuthenticator with a backend callback endpoint:
public async Task<string?> SignInWithGoogleAsync()
{
try
{
var authResult = await WebAuthenticator.Default.AuthenticateAsync(
new Uri("https://yourapi.com/auth/google-login"),
new Uri("yourapp://callback"));
// Extract the access token from the callback
if (authResult.Properties.TryGetValue("access_token", out var accessToken))
{
// Store securely
await SecureStorage.Default.SetAsync("access_token", accessToken);
return accessToken;
}
return null;
}
catch (TaskCanceledException)
{
// User cancelled the authentication
return null;
}
}
The backend endpoint handles the actual OAuth dance with Google and redirects back to your app's custom URI scheme with the tokens. This keeps your client secrets on the server where they belong.
Apple Sign-In: A Platform Requirement
Here's something that catches a lot of developers off guard: if your iOS app offers any third-party social login (Google, Facebook, etc.), Apple's App Store Review Guidelines require you to also offer Sign In with Apple. This isn't optional — I've seen apps get rejected for missing it.
The implementation follows the same WebAuthenticator pattern, but you'll need to configure the "Sign in with Apple" capability in your Apple Developer account and add the entitlement to your app.
On the server side, the AspNet.Security.OAuth.Apple NuGet package simplifies the integration with ASP.NET Core backends. Keep in mind that Apple's token verification requires a key from your Apple Developer account, which should never be embedded in the mobile app itself.
Platform Limitations to Watch For
One critical limitation worth calling out: WebAuthenticator does not currently support Windows. If you're targeting Windows alongside mobile platforms, you'll need to implement a platform-specific alternative for desktop authentication — typically using a localhost redirect or the platform's native WAM (Web Account Manager) broker.
Biometric Authentication: Fingerprint and Face ID
Biometric authentication adds a crucial layer of local device security. It's not a replacement for server-side authentication — rather, it guards access to cached credentials and sensitive app sections. Users expect biometric support in any app that handles sensitive data these days, and implementing it in .NET MAUI is pretty straightforward with the right library.
Setting Up Plugin.Maui.Biometric
The Maui.Biometric library (a continuation of the Plugin.Fingerprint ecosystem) provides cross-platform biometric authentication for iOS, Android, macOS, and Windows:
dotnet add package Plugin.Maui.Biometric
Register it in your MauiProgram.cs:
using Plugin.Maui.Biometric;
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()
.ConfigureFonts(fonts =>
{
fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
});
// Register biometric service
builder.Services.AddSingleton<IBiometric>(BiometricAuthenticationService.Default);
return builder.Build();
}
Implementing Biometric Checks
Before prompting for biometric authentication, always check device capability first. Not every device supports biometrics, and some users simply haven't enrolled any fingerprints or face data:
public class BiometricService
{
private readonly IBiometric _biometric;
public BiometricService(IBiometric biometric)
{
_biometric = biometric;
}
public async Task<bool> AuthenticateAsync()
{
// Check if biometrics are available
var availability = await _biometric.GetAvailabilityAsync();
if (availability != BiometricAvailability.Available)
{
// Biometrics not available — fall back to PIN or skip
return false;
}
var request = new AuthenticationRequest
{
Title = "Verify Your Identity",
NegativeText = "Use Password Instead",
AllowAlternativeAuthentication = true // Allow PIN/password fallback
};
var result = await _biometric.AuthenticateAsync(request);
return result.Status == BiometricResponseStatus.Success;
}
}
Platform-Specific Permissions
iOS: Add the NSFaceIDUsageDescription key to your Info.plist explaining why your app needs Face ID access. Without this, your app will crash when attempting Face ID — no warning, just a crash:
<key>NSFaceIDUsageDescription</key>
<string>We use Face ID to securely verify your identity before accessing sensitive data.</string>
Android: Add the biometric permission to your AndroidManifest.xml. Biometric authentication on Android requires at minimum API level 21 (Lollipop), though the more modern BiometricPrompt API requires API 28+:
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
Combining Biometrics with Token-Based Auth
The real power comes from combining biometric authentication with your token management flow. Use biometrics to gate access to stored refresh tokens, giving users a seamless yet secure re-authentication experience:
public async Task<string?> GetAccessTokenWithBiometricAsync()
{
// Step 1: Verify user identity with biometrics
var biometricService = new BiometricService(_biometric);
var isAuthenticated = await biometricService.AuthenticateAsync();
if (!isAuthenticated)
{
return null; // User failed biometric check
}
// Step 2: Retrieve stored refresh token
var refreshToken = await SecureStorage.Default.GetAsync("refresh_token");
if (string.IsNullOrEmpty(refreshToken))
{
return null; // No stored token — need full login
}
// Step 3: Exchange refresh token for new access token
return await ExchangeRefreshTokenAsync(refreshToken);
}
Secure Token Storage: Getting It Right on Every Platform
.NET MAUI's SecureStorage API is your primary tool for storing sensitive data like authentication tokens, API keys, and encryption keys. Under the hood, it leverages platform-native security mechanisms that are dramatically more secure than plain text file storage or SharedPreferences.
How SecureStorage Works Internally
Understanding the underlying mechanisms helps you appreciate what SecureStorage actually protects against — and what it doesn't. This is worth knowing because it shapes your threat model:
- Android: Uses
EncryptedSharedPreferencesfrom the AndroidX Security library. Keys are deterministically encrypted and values use AES-256-GCM non-deterministic encryption. The master key is stored in the Android Keystore, which is hardware-backed on most modern devices. - iOS/macOS: Uses the system Keychain, which provides hardware-backed encryption on devices with a Secure Enclave (iPhone 5S and later). Keychain items are protected by the device passcode and biometric data.
- Windows: Uses
DataProtectionProvider, which encrypts data using the user's Windows credentials. Encrypted values are stored inApplicationData.Current.LocalSettings.
Working with SecureStorage
The API itself is straightforward, but there are patterns you should follow consistently:
public class TokenStorageService
{
private const string AccessTokenKey = "access_token";
private const string RefreshTokenKey = "refresh_token";
private const string TokenExpiryKey = "token_expiry";
public async Task StoreTokensAsync(string accessToken, string refreshToken,
DateTimeOffset expiry)
{
await SecureStorage.Default.SetAsync(AccessTokenKey, accessToken);
await SecureStorage.Default.SetAsync(RefreshTokenKey, refreshToken);
await SecureStorage.Default.SetAsync(TokenExpiryKey,
expiry.ToUnixTimeSeconds().ToString());
}
public async Task<(string? AccessToken, string? RefreshToken, DateTimeOffset? Expiry)>
GetTokensAsync()
{
var accessToken = await SecureStorage.Default.GetAsync(AccessTokenKey);
var refreshToken = await SecureStorage.Default.GetAsync(RefreshTokenKey);
var expiryStr = await SecureStorage.Default.GetAsync(TokenExpiryKey);
DateTimeOffset? expiry = null;
if (long.TryParse(expiryStr, out var unixSeconds))
{
expiry = DateTimeOffset.FromUnixTimeSeconds(unixSeconds);
}
return (accessToken, refreshToken, expiry);
}
public void ClearTokens()
{
SecureStorage.Default.Remove(AccessTokenKey);
SecureStorage.Default.Remove(RefreshTokenKey);
SecureStorage.Default.Remove(TokenExpiryKey);
}
public async Task<bool> HasValidTokenAsync()
{
var (accessToken, _, expiry) = await GetTokensAsync();
return !string.IsNullOrEmpty(accessToken)
&& expiry.HasValue
&& expiry.Value > DateTimeOffset.UtcNow;
}
}
SecureStorage Limitations and Workarounds
SecureStorage is designed for small key-value pairs — think tokens, API keys, and short secrets. It's not appropriate for large data blobs. If you need to encrypt larger datasets, use SecureStorage to store an encryption key and then use that key with standard cryptographic APIs to encrypt your data files.
Another gotcha (and this one bit me on a real project): on Android, uninstalling and reinstalling the app makes previously stored SecureStorage values inaccessible because the encryption keys are regenerated. On iOS, Keychain items can actually persist across reinstalls by default. Design your token refresh flow to handle this discrepancy gracefully — always be prepared for SecureStorage to return null even when you expect data to be there.
Certificate Pinning: Trusting Only Your Server
HTTPS protects your network traffic from eavesdropping, but it has a weakness: the trust chain relies on certificate authorities (CAs). If an attacker compromises a CA, installs a root certificate on the device, or uses a corporate proxy that performs TLS interception, they can intercept your "encrypted" traffic. Certificate pinning eliminates this attack vector by hard-coding which certificates or public keys your app trusts.
Implementing Certificate Pinning in .NET MAUI
There are several approaches to certificate pinning in .NET MAUI. The most maintainable approach uses a custom HttpMessageHandler that validates the server's certificate against your expected public key hash:
public class CertificatePinningHandler : HttpClientHandler
{
private readonly HashSet<string> _pinnedPublicKeyHashes;
public CertificatePinningHandler(IEnumerable<string> pinnedHashes)
{
_pinnedPublicKeyHashes = new HashSet<string>(pinnedHashes);
ServerCertificateCustomValidationCallback = ValidateCertificate;
}
private bool ValidateCertificate(
HttpRequestMessage request,
X509Certificate2? certificate,
X509Chain? chain,
SslPolicyErrors sslErrors)
{
if (certificate == null) return false;
// Check standard SSL validation first
if (sslErrors != SslPolicyErrors.None) return false;
// Compute the SHA-256 hash of the certificate's public key
using var sha256 = SHA256.Create();
var publicKeyBytes = certificate.GetPublicKey();
var hashBytes = sha256.ComputeHash(publicKeyBytes);
var hashString = Convert.ToBase64String(hashBytes);
return _pinnedPublicKeyHashes.Contains(hashString);
}
}
Register the pinning handler in your dependency injection container:
builder.Services.AddHttpClient("SecureApi", client =>
{
client.BaseAddress = new Uri("https://api.yourservice.com");
})
.ConfigurePrimaryHttpMessageHandler(() => new CertificatePinningHandler(
new[]
{
"YourBase64EncodedPublicKeyHash1=",
"YourBase64EncodedPublicKeyHash2=" // Backup pin for key rotation
}
));
Android Network Security Configuration
On Android, you can also configure certificate pinning declaratively using the network security configuration. Create a file at Resources/xml/network_security_config.xml:
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<domain-config>
<domain includeSubdomains="true">api.yourservice.com</domain>
<pin-set expiration="2027-01-01">
<pin digest="SHA-256">YourBase64EncodedPin1=</pin>
<pin digest="SHA-256">YourBase64EncodedPin2=</pin>
</pin-set>
</domain-config>
</network-security-config>
Then reference it in your AndroidManifest.xml:
<application android:networkSecurityConfig="@xml/network_security_config" />
Always include at least two pins — a primary and a backup. If your certificate expires or you need to rotate keys, having a backup pin prevents your app from becoming completely non-functional. Set a reasonable expiration date on the pin set to force periodic reviews.
Building a Complete Authentication Architecture
So, let's tie everything together into a cohesive authentication architecture. A well-designed auth system for a .NET MAUI app should handle the full lifecycle: initial login, token refresh, biometric re-authentication, and graceful session expiration.
The AuthManager: Coordinating Authentication
public class AuthManager
{
private readonly AuthService _authService;
private readonly BiometricService _biometricService;
private readonly TokenStorageService _tokenStorage;
public AuthManager(
AuthService authService,
BiometricService biometricService,
TokenStorageService tokenStorage)
{
_authService = authService;
_biometricService = biometricService;
_tokenStorage = tokenStorage;
}
public async Task<AuthState> GetAuthStateAsync()
{
// Check if we have stored tokens
if (!await _tokenStorage.HasValidTokenAsync())
{
// Check if we have a refresh token
var (_, refreshToken, _) = await _tokenStorage.GetTokensAsync();
if (!string.IsNullOrEmpty(refreshToken))
{
return AuthState.RequiresBiometric;
}
return AuthState.RequiresLogin;
}
return AuthState.Authenticated;
}
public async Task<bool> TryResumeSessionAsync()
{
var state = await GetAuthStateAsync();
switch (state)
{
case AuthState.Authenticated:
return true;
case AuthState.RequiresBiometric:
var biometricPassed = await _biometricService.AuthenticateAsync();
if (!biometricPassed) return false;
// Refresh tokens after biometric verification
var result = await _authService.SignInAsync();
if (result != null)
{
await _tokenStorage.StoreTokensAsync(
result.AccessToken,
result.AccessToken, // MSAL handles refresh internally
result.ExpiresOn);
return true;
}
return false;
case AuthState.RequiresLogin:
default:
return false;
}
}
public async Task<bool> SignInAsync()
{
var result = await _authService.SignInAsync();
if (result == null) return false;
await _tokenStorage.StoreTokensAsync(
result.AccessToken,
result.AccessToken,
result.ExpiresOn);
return true;
}
public async Task SignOutAsync()
{
await _authService.SignOutAsync();
_tokenStorage.ClearTokens();
}
}
public enum AuthState
{
RequiresLogin,
RequiresBiometric,
Authenticated
}
Integrating with Dependency Injection
Register all your authentication services in MauiProgram.cs:
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()
.ConfigureFonts(fonts =>
{
fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
});
// Authentication services
builder.Services.AddSingleton<AuthService>();
builder.Services.AddSingleton<TokenStorageService>();
builder.Services.AddSingleton<IBiometric>(BiometricAuthenticationService.Default);
builder.Services.AddSingleton<BiometricService>();
builder.Services.AddSingleton<AuthManager>();
// HTTP client with certificate pinning
builder.Services.AddHttpClient("Api", client =>
{
client.BaseAddress = new Uri("https://api.yourservice.com");
})
.ConfigurePrimaryHttpMessageHandler(() => new CertificatePinningHandler(
new[] { "YourPinHash=" }
));
return builder.Build();
}
Protecting Navigation with Auth Guards
Use Shell navigation with route guards to prevent unauthorized access to protected pages. This pattern works well with the AuthManager:
public partial class AppShell : Shell
{
private readonly AuthManager _authManager;
public AppShell(AuthManager authManager)
{
InitializeComponent();
_authManager = authManager;
Routing.RegisterRoute("login", typeof(LoginPage));
Routing.RegisterRoute("dashboard", typeof(DashboardPage));
Navigating += OnNavigating;
}
private async void OnNavigating(object? sender, ShellNavigatingEventArgs e)
{
// Allow navigation to login page always
if (e.Target.Location.OriginalString.Contains("login"))
return;
var state = await _authManager.GetAuthStateAsync();
if (state == AuthState.RequiresLogin)
{
e.Cancel();
await GoToAsync("//login");
}
}
}
Defense in Depth: Additional Security Hardening
Authentication and token management are the foundation, but a truly secure mobile app implements multiple layers of defense. Let's go through the additional hardening measures that every production .NET MAUI app should consider.
Protecting Against Reverse Engineering
.NET MAUI apps can be decompiled, and any strings embedded in your code — API endpoints, configuration values, even the structure of your authentication flow — are visible to attackers. While you can't completely prevent reverse engineering, you can make it significantly harder:
- Never embed secrets in client code. API keys, client secrets, and encryption keys should come from your backend or be derived at runtime.
- Use NativeAOT compilation where possible. Ahead-of-time compiled native code is harder to reverse engineer than IL assemblies. .NET MAUI 9 and 10 have improved NativeAOT support significantly.
- Enable code obfuscation for release builds. Tools like Dotfuscator can rename methods, encrypt strings, and add control flow obfuscation.
- Implement root/jailbreak detection to warn users or restrict functionality on compromised devices.
Secure Network Communication
Beyond certificate pinning, implement these network security practices:
// Configure HttpClient with security best practices
builder.Services.AddHttpClient("SecureApi", client =>
{
client.BaseAddress = new Uri("https://api.yourservice.com");
client.DefaultRequestHeaders.Add("X-Request-Id", Guid.NewGuid().ToString());
client.Timeout = TimeSpan.FromSeconds(30);
})
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
{
// Enforce TLS 1.2 or higher
SslProtocols = System.Security.Authentication.SslProtocols.Tls12
| System.Security.Authentication.SslProtocols.Tls13,
// Disable insecure protocols
AllowAutoRedirect = false, // Prevent redirect-based attacks
});
Token Refresh and Expiration Strategy
A robust token management strategy prevents both security vulnerabilities and poor user experiences. Here's what I'd recommend:
- Short-lived access tokens (5-15 minutes) limit the damage window if a token is compromised.
- Longer-lived refresh tokens (days to weeks) enable seamless re-authentication without requiring the user to log in again.
- Proactive token refresh: Don't wait for an API call to fail with a 401. Check token expiry before making requests and refresh proactively when the token is within a few minutes of expiring.
- Refresh token rotation: Each time you use a refresh token, the server should issue a new one and invalidate the old one. This ensures that a stolen refresh token can only be used once.
public class AuthenticatedHttpHandler : DelegatingHandler
{
private readonly TokenStorageService _tokenStorage;
private readonly AuthService _authService;
private readonly SemaphoreSlim _refreshLock = new(1, 1);
public AuthenticatedHttpHandler(
TokenStorageService tokenStorage,
AuthService authService)
{
_tokenStorage = tokenStorage;
_authService = authService;
}
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
// Proactively refresh if token expires within 2 minutes
var (accessToken, _, expiry) = await _tokenStorage.GetTokensAsync();
if (expiry.HasValue && expiry.Value < DateTimeOffset.UtcNow.AddMinutes(2))
{
await _refreshLock.WaitAsync(cancellationToken);
try
{
// Double-check after acquiring lock
var (currentToken, _, currentExpiry) =
await _tokenStorage.GetTokensAsync();
if (currentExpiry.HasValue &&
currentExpiry.Value < DateTimeOffset.UtcNow.AddMinutes(2))
{
var result = await _authService.SignInAsync();
if (result != null)
{
accessToken = result.AccessToken;
await _tokenStorage.StoreTokensAsync(
result.AccessToken,
result.AccessToken,
result.ExpiresOn);
}
}
else
{
accessToken = currentToken;
}
}
finally
{
_refreshLock.Release();
}
}
if (!string.IsNullOrEmpty(accessToken))
{
request.Headers.Authorization =
new AuthenticationHeaderValue("Bearer", accessToken);
}
var response = await base.SendAsync(request, cancellationToken);
// Handle 401 — force re-login
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{
_tokenStorage.ClearTokens();
// Notify the app to redirect to login
WeakReferenceMessenger.Default.Send(new SessionExpiredMessage());
}
return response;
}
}
Secure Data at Rest
Beyond SecureStorage for tokens, consider encrypting your local database. If you're using SQLite for offline data, SQLCipher provides transparent AES-256 encryption for the entire database file. The encryption key itself can be stored in SecureStorage, creating a chain of trust that leverages platform-native hardware security.
Security Checklist for Production .NET MAUI Apps
Before shipping your app to the stores, run through this checklist. Each item addresses a real vulnerability that has been exploited in production mobile apps:
- Authentication: OAuth 2.0 with PKCE using system browser — never store passwords locally.
- Token Storage: All tokens in SecureStorage, never in Preferences or plain files.
- Certificate Pinning: At least two pins (primary + backup) with an expiration-based rotation plan.
- Biometric Gating: Require biometric verification to access stored credentials on app resume.
- Network Security: TLS 1.2 minimum, no HTTP fallback, disable auto-redirect on HttpClient.
- Debug Protection: Disable debug logging and verbose error messages in release builds.
- Code Protection: Enable obfuscation and consider NativeAOT for critical modules.
- Input Validation: Validate all data from external sources — API responses, deep links, and intent extras.
- Session Management: Implement proper sign-out that clears all cached tokens and session data.
- Dependency Audit: Regularly scan NuGet dependencies for known vulnerabilities using
dotnet list package --vulnerable.
Looking Ahead: Security in .NET MAUI 10 and Beyond
.NET MAUI 10, released as an LTS release in November 2025, continues to improve the security landscape. The XAML source generator reduces runtime overhead and attack surface by creating strongly-typed code at compile time. WebView request interception capabilities give developers more control over what content loads in hybrid scenarios. The unified permissions model now extends to more platform capabilities, providing consistent security prompts across platforms.
The broader .NET 10 ecosystem brings Pushed Authorization Requests (PAR) support in ASP.NET Core, which moves authorization parameters from the front channel (URL query strings visible in browser history and logs) to the back channel, further hardening OAuth flows. This is particularly relevant for .NET MAUI apps communicating with ASP.NET Core backends.
As the framework matures, expect even deeper integration with platform-native security features. The trend is clearly toward hardware-backed security, zero-trust architectures, and making secure defaults the path of least resistance.
Wrapping Up
Security in mobile apps isn't a feature you bolt on at the end — it's an architectural decision that influences how you build everything from your login screen to your API client. The patterns we've covered here — MSAL-based authentication, biometric verification, secure token storage, certificate pinning, and proactive token management — form a defense-in-depth strategy that protects your users against real-world threats.
The key takeaway: don't try to roll your own security. Use MSAL.NET for identity provider integration. Use SecureStorage for sensitive data. Use platform-native biometric APIs. Use certificate pinning for network trust. Each of these tools exists because security professionals have already thought through the edge cases and failure modes that you'd otherwise have to discover the hard way.
Start with authentication and token management, then layer on biometrics and certificate pinning as your app's threat model demands. And always remember — the most secure mobile app is one where security decisions are invisible to the user. When your auth flows are smooth, your biometric prompts are timely, and your token refreshes are seamless, users get both security and a great experience. That's what we should all be aiming for.