
Maui Authentication
- 38 installs
- 163 repo stars
- Updated July 6, 2026
- davidortinau/maui-skills
Adds authentication to .NET MAUI apps using WebAuthenticator for OAuth 2.0 / social login and MSAL.NET for Microsoft Entra ID (Azure AD).
About
Covers adding authentication to .NET MAUI apps via WebAuthenticator for generic OAuth 2.0 and social login, and MSAL.NET for Microsoft Entra ID. A developer uses it when integrating sign-in and OAuth flows into a MAUI app.
- WebAuthenticator for OAuth 2.0 and social login
- MSAL.NET for Microsoft Entra ID (Azure AD)
Maui Authentication by the numbers
- 38 all-time installs (skills.sh)
- Ranked #641 of 1,039 Mobile Development skills by installs in the Skillselion catalog
- Data as of Aug 3, 2026 (Skillselion catalog sync)
npx skills add https://github.com/davidortinau/maui-skills --skill maui-authenticationAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 38 |
|---|---|
| repo stars | ★ 163 |
| Last updated | July 6, 2026 |
| Repository | davidortinau/maui-skills ↗ |
What it does
Adds authentication to .NET MAUI apps using WebAuthenticator for OAuth 2.0 / social login and MSAL.NET for Microsoft Entra ID (Azure AD).
Files
.NET MAUI Authentication
Security: Never Embed Secrets
❌ Never embed client secrets, API keys, or signing keys in a mobile app binary. They can be extracted trivially via decompilation.
The correct pattern: 1. App calls WebAuthenticator pointing to your server endpoint 2. Server initiates the OAuth flow with the identity provider (holds the client secret) 3. Provider redirects back to your server with an auth code 4. Server exchanges the code for tokens and returns them to the app via the callback URI
WebAuthenticator Gotchas
⚠️ Windows WebAuthenticator is broken
Windows WebAuthenticator is currently broken. See dotnet/maui#2702. Use MSAL or a WinUI-specific workaround for Windows auth flows.
⚠️ Apple Sign In returns name/email only once
Apple only returns the user's name and email on the first sign-in. Cache them immediately — subsequent sign-ins won't include them.
⚠️ PrefersEphemeralWebBrowserSession
Set to true on iOS 13+ to force a fresh login prompt. When false (default), the auth session shares cookies with Safari — the user may be auto-logged in, which can confuse logout/switch-account flows.
⚠️ Callback URI mismatches
The most common auth failure is a URI scheme mismatch. The CallbackUrl in code must exactly match:
- Android:
DataScheme+DataHostin theIntentFilter - iOS:
CFBundleURLSchemesinInfo.plist - Windows:
Protocol NameinPackage.appxmanifest
WebAuthenticator Checklist
- [ ] Callback URI scheme matches across all platform configs and
CallbackUrl - [ ] Android has
WebAuthenticatorCallbackActivitywith correctIntentFilter - [ ] Android 11+ has
<queries>for Custom Tabs in the manifest - [ ] iOS/Mac Catalyst has
CFBundleURLTypesinInfo.plist - [ ] Client secrets are on the server, not in the app
- [ ] Tokens stored with
SecureStorage, cleared on logout - [ ]
TaskCanceledExceptionhandled gracefully in UI
---
Choosing Between WebAuthenticator and MSAL.NET
| Criteria | WebAuthenticator | MSAL.NET |
|---|---|---|
| Identity provider | Any OAuth 2.0 / OIDC | Microsoft Entra ID |
| Broker support (SSO) | ❌ No | ✅ Microsoft Authenticator, Company Portal |
| Conditional Access / MFA | ❌ Manual | ✅ Built-in |
| Token cache & refresh | ❌ Manual (SecureStorage) | ✅ Automatic |
| Complexity | Simple | More setup |
| Use when | Google, Apple, generic OIDC | Entra ID / Azure AD, Microsoft Graph |
---
MSAL.NET Gotchas
⚠️ Android: OnActivityResult is required
Forgetting AuthenticationContinuationHelper.SetAuthenticationContinuationEventArgs in MainActivity.OnActivityResult causes auth to hang silently after the browser returns.
⚠️ iOS: Keychain sharing is required
Without Entitlements.plist containing keychain group com.microsoft.adalcache, token caching fails silently and users are prompted to sign in every time.
⚠️ Handle MsalUiRequiredException
When AcquireTokenSilent throws MsalUiRequiredException, the cached token is expired and interaction is needed. Always fall back to AcquireTokenInteractive.
// ❌ Ignoring MsalUiRequiredException — user gets a crash
var result = await _pca.AcquireTokenSilent(scopes, account).ExecuteAsync(ct);
// ✅ Fall back to interactive when silent fails
try
{
result = await _pca.AcquireTokenSilent(scopes, account).ExecuteAsync(ct);
}
catch (MsalUiRequiredException)
{
result = await _pca.AcquireTokenInteractive(scopes).ExecuteAsync(ct);
}⚠️ Handle user cancellation gracefully
// ✅ Don't treat cancellation as an error
catch (MsalClientException ex) when (ex.ErrorCode == "authentication_canceled")
{
return null; // User cancelled — not an error
}⚠️ Blazor Hybrid: Auth happens at the MAUI layer
In MAUI Blazor Hybrid apps, authentication must happen at the MAUI layer (MSAL.NET), not in the Blazor WebView. Don't use AddMicrosoftIdentityWebApp or server-side OIDC patterns. Instead: 1. MAUI handles sign-in via IAuthService (MSAL.NET) 2. A custom MsalAuthenticationStateProvider exposes auth state to Blazor 3. HttpClient with DelegatingHandler attaches bearer tokens automatically
MSAL.NET Checklist
- [ ]
Microsoft.Identity.ClientNuGet package added - [ ] App registration created in Entra ID with correct redirect URIs
- [ ]
AuthConfig/appsettings.jsonhas ClientId, TenantId, Scopes - [ ] Android:
AndroidManifest.xmlhas<queries>for broker and browsers - [ ] Android:
MainActivity.OnActivityResultcallsAuthenticationContinuationHelper - [ ] iOS:
Info.plisthasCFBundleURLSchemeswithmsauth.{BundleId} - [ ] iOS:
Entitlements.plisthas keychain groupcom.microsoft.adalcache - [ ] iOS:
AppDelegate.OpenUrlcallsAuthenticationContinuationHelper - [ ]
IAuthServiceregistered as singleton in DI - [ ]
DelegatingHandlerattached toHttpClientfor API calls - [ ] Login/logout UI wired up
- [ ]
MsalUiRequiredExceptionhandled (triggers interactive sign-in) - [ ]
MsalClientExceptionwithauthentication_canceledhandled gracefully
.NET MAUI Authentication — API Reference
WebAuthenticator Core API
var result = await WebAuthenticator.Default.AuthenticateAsync(
new WebAuthenticatorOptions
{
Url = new Uri("https://your-server.com/auth/login"),
CallbackUrl = new Uri("myapp://callback"),
PrefersEphemeralWebBrowserSession = true
});
string accessToken = result.AccessToken;
string refreshToken = result.Properties["refresh_token"];Url— the authorization endpoint (your server or identity provider).CallbackUrl— the URI scheme your app is registered to handle.PrefersEphemeralWebBrowserSession— whentrue(iOS 13+), uses a private browser session that does not share cookies or data with Safari.
WebAuthenticator Platform Setup
Android
1. Callback Activity
using Android.App;
using Android.Content.PM;
namespace MyApp.Platforms.Android;
[Activity(NoHistory = true, LaunchMode = LaunchMode.SingleTop, Exported = true)]
[IntentFilter(
new[] { Android.Content.Intent.ActionView },
Categories = new[] { Android.Content.Intent.CategoryDefault, Android.Content.Intent.CategoryBrowsable },
DataScheme = "myapp",
DataHost = "callback")]
public class WebAuthenticationCallbackActivity : Microsoft.Maui.Authentication.WebAuthenticatorCallbackActivity
{
}2. Package Visibility (Android 11+)
<manifest>
<queries>
<intent>
<action android:name="android.support.customtabs.action.CustomTabsService" />
</intent>
</queries>
</manifest>iOS / Mac Catalyst
Register the callback URI scheme in Info.plist:
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>myapp</string>
<key>CFBundleURLSchemes</key>
<array>
<string>myapp</string>
</array>
</dict>
</array>No additional code is needed — MAUI handles the callback automatically on Apple platforms.
Windows
Register the protocol in Package.appxmanifest:
<Extensions>
<uap:Extension Category="windows.protocol">
<uap:Protocol Name="myapp">
<uap:DisplayName>My App Auth</uap:DisplayName>
</uap:Protocol>
</uap:Extension>
</Extensions>Apple Sign In
var result = await AppleSignInAuthenticator.Default.AuthenticateAsync(
new AppleSignInAuthenticator.Options
{
IncludeFullNameScope = true,
IncludeEmailScope = true
});
string idToken = result.IdToken;
string name = result.Properties["name"];Token Persistence with SecureStorage
// Save
await SecureStorage.Default.SetAsync("access_token", accessToken);
await SecureStorage.Default.SetAsync("refresh_token", refreshToken);
// Retrieve
string token = await SecureStorage.Default.GetAsync("access_token");
// Clear on logout
SecureStorage.Default.RemoveAll();DI-Friendly WebAuth Service
public interface IAuthService
{
Task<AuthResult> LoginAsync(CancellationToken ct = default);
Task LogoutAsync();
Task<string?> GetAccessTokenAsync();
}
public record AuthResult(bool Success, string? ErrorMessage = null);
public class WebAuthService : IAuthService
{
private const string AuthUrl = "https://your-server.com/auth/login";
private const string CallbackUrl = "myapp://callback";
public async Task<AuthResult> LoginAsync(CancellationToken ct = default)
{
try
{
var result = await WebAuthenticator.Default.AuthenticateAsync(
new WebAuthenticatorOptions
{
Url = new Uri(AuthUrl),
CallbackUrl = new Uri(CallbackUrl),
PrefersEphemeralWebBrowserSession = true
});
await SecureStorage.Default.SetAsync("access_token", result.AccessToken);
return new AuthResult(true);
}
catch (TaskCanceledException)
{
return new AuthResult(false, "Login cancelled.");
}
}
public Task LogoutAsync()
{
SecureStorage.Default.RemoveAll();
return Task.CompletedTask;
}
public Task<string?> GetAccessTokenAsync()
=> SecureStorage.Default.GetAsync("access_token");
}Register in MauiProgram.cs:
builder.Services.AddSingleton<IAuthService, WebAuthService>();---
MSAL.NET — Entra ID App Registration
Manual registration
1. Go to Microsoft Entra admin center → App registrations → New registration 2. Name: your app name 3. Supported account types: choose your scenario (single tenant, multi-tenant, personal accounts) 4. Do NOT set a redirect URI yet — add platform-specific URIs after:
- Add a platform → Mobile and desktop applications
- Android:
msal{ClientId}://auth - iOS:
msauth.{BundleId}://auth - Windows/macOS:
http://localhost
5. Note the Application (client) ID and Directory (tenant) ID 6. Under API permissions, add User.Read (Microsoft Graph) for basic profile access 7. If calling your own API: register the API app separately, expose a scope (e.g., access_as_user), then add that scope as a permission to the client app
Install the Entra provisioning skill (automated alternative)
mkdir -p .github/skills && cd .github/skills
curl -LO https://aka.ms/msidweb/aspire/entra-id-provisioning-skillThen ask your AI assistant: "Provision Entra ID app registrations for my MAUI app"
Source: https://github.com/AzureAD/microsoft-identity-web/tree/master/.github/skills
MSAL.NET Configuration
{
"AzureAd": {
"Authority": "https://login.microsoftonline.com/{TenantId}",
"TenantId": "<your-tenant-id>",
"ClientId": "<your-client-id>",
"Scopes": "User.Read"
}
}Or use a config class for mobile:
public static class AuthConfig
{
public const string TenantId = "<your-tenant-id>";
public const string ClientId = "<your-client-id>";
public const string Authority = $"https://login.microsoftonline.com/{TenantId}";
public static readonly string[] Scopes = ["User.Read"];
public const string AndroidRedirectUri = $"msal{ClientId}://auth";
public const string IosRedirectUri = $"msauth.com.companyname.myapp://auth";
}MSAL Auth Service
public interface IAuthService
{
Task<AuthenticationResult?> SignInAsync(CancellationToken ct = default);
Task<AuthenticationResult?> AcquireTokenSilentAsync(CancellationToken ct = default);
Task SignOutAsync();
Task<string?> GetAccessTokenAsync(string[] scopes, CancellationToken ct = default);
bool IsSignedIn { get; }
}using Microsoft.Identity.Client;
public class MsalAuthService : IAuthService
{
private readonly IPublicClientApplication _pca;
private readonly string[] _defaultScopes;
public bool IsSignedIn => _cachedAccount != null;
private IAccount? _cachedAccount;
public MsalAuthService()
{
_defaultScopes = AuthConfig.Scopes;
var builder = PublicClientApplicationBuilder
.Create(AuthConfig.ClientId)
.WithAuthority(AuthConfig.Authority)
.WithIosKeychainSecurityGroup("com.microsoft.adalcache");
#if ANDROID
builder = builder.WithRedirectUri(AuthConfig.AndroidRedirectUri)
.WithParentActivityOrWindow(() => Platform.CurrentActivity);
#elif IOS || MACCATALYST
builder = builder.WithRedirectUri(AuthConfig.IosRedirectUri);
#else
builder = builder.WithRedirectUri("http://localhost");
#endif
#if ANDROID || IOS
builder = builder.WithBroker();
#endif
_pca = builder.Build();
}
public async Task<AuthenticationResult?> SignInAsync(CancellationToken ct = default)
{
var result = await AcquireTokenSilentAsync(ct);
if (result != null) return result;
try
{
result = await _pca.AcquireTokenInteractive(_defaultScopes)
.WithLoginHint(_cachedAccount?.Username)
#if ANDROID
.WithParentActivityOrWindow(Platform.CurrentActivity)
#endif
.ExecuteAsync(ct);
_cachedAccount = result.Account;
return result;
}
catch (MsalClientException ex) when (ex.ErrorCode == "authentication_canceled")
{
return null;
}
}
public async Task<AuthenticationResult?> AcquireTokenSilentAsync(CancellationToken ct = default)
{
try
{
var accounts = await _pca.GetAccountsAsync();
_cachedAccount = accounts.FirstOrDefault();
if (_cachedAccount == null) return null;
var result = await _pca.AcquireTokenSilent(_defaultScopes, _cachedAccount)
.ExecuteAsync(ct);
_cachedAccount = result.Account;
return result;
}
catch (MsalUiRequiredException)
{
return null;
}
}
public async Task<string?> GetAccessTokenAsync(string[] scopes, CancellationToken ct = default)
{
var accounts = await _pca.GetAccountsAsync();
var account = accounts.FirstOrDefault();
if (account == null) return null;
try
{
var result = await _pca.AcquireTokenSilent(scopes, account)
.ExecuteAsync(ct);
return result.AccessToken;
}
catch (MsalUiRequiredException)
{
var result = await _pca.AcquireTokenInteractive(scopes)
#if ANDROID
.WithParentActivityOrWindow(Platform.CurrentActivity)
#endif
.ExecuteAsync(ct);
_cachedAccount = result.Account;
return result.AccessToken;
}
}
public async Task SignOutAsync()
{
var accounts = await _pca.GetAccountsAsync();
foreach (var account in accounts)
{
await _pca.RemoveAsync(account);
}
_cachedAccount = null;
}
}Register in MauiProgram.cs:
builder.Services.AddSingleton<IAuthService, MsalAuthService>();MSAL Platform Setup
Android
AndroidManifest.xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application android:allowBackup="true" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
<queries>
<package android:name="com.azure.authenticator" />
<package android:name="com.microsoft.windowsintune.companyportal" />
<intent>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
</intent>
<intent>
<action android:name="android.support.customtabs.action.CustomTabsService" />
</intent>
</queries>
</manifest>MainActivity.cs
using Android.App;
using Android.Content;
using Android.Content.PM;
using Android.OS;
using Android.Runtime;
using Microsoft.Identity.Client;
namespace MyApp;
[Activity(Theme = "@style/Maui.SplashTheme", MainLauncher = true,
ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation |
ConfigChanges.UiMode | ConfigChanges.ScreenLayout |
ConfigChanges.SmallestScreenSize | ConfigChanges.Density)]
public class MainActivity : MauiAppCompatActivity
{
protected override void OnActivityResult(int requestCode,
[GeneratedEnum] Result resultCode, Intent? data)
{
base.OnActivityResult(requestCode, resultCode, data);
AuthenticationContinuationHelper
.SetAuthenticationContinuationEventArgs(requestCode, resultCode, data);
}
}iOS / Mac Catalyst
Info.plist
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>com.companyname.myapp</string>
<key>CFBundleURLSchemes</key>
<array>
<string>msauth.com.companyname.myapp</string>
</array>
</dict>
</array>Entitlements.plist
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)com.microsoft.adalcache</string>
</array>AppDelegate.cs
using Foundation;
using Microsoft.Identity.Client;
using UIKit;
namespace MyApp;
[Register("AppDelegate")]
public class AppDelegate : MauiUIApplicationDelegate
{
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
public override bool OpenUrl(UIApplication app, NSUrl url,
NSDictionary options)
{
AuthenticationContinuationHelper.SetAuthenticationContinuationEventArgs(url);
return base.OpenUrl(app, url, options);
}
}Windows
No special platform setup. MSAL uses http://localhost redirect by default. For broker (WAM) support on Windows, add:
#if WINDOWS
using Microsoft.Identity.Client.Desktop;
builder = builder.WithBroker(new BrokerOptions(BrokerOptions.OperatingSystems.Windows));
#endifBearer Token DelegatingHandler
public class AuthTokenHandler : DelegatingHandler
{
private readonly IAuthService _authService;
private readonly string[] _scopes;
public AuthTokenHandler(IAuthService authService, string[] scopes)
{
_authService = authService;
_scopes = scopes;
InnerHandler = new HttpClientHandler();
}
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken ct)
{
var token = await _authService.GetAccessTokenAsync(_scopes, ct);
if (token != null)
{
request.Headers.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
}
return await base.SendAsync(request, ct);
}
}Register in MauiProgram.cs:
builder.Services.AddTransient(sp =>
new AuthTokenHandler(
sp.GetRequiredService<IAuthService>(),
new[] { "api://<your-api-client-id>/access_as_user" }));
builder.Services.AddHttpClient<IMyApiClient, MyApiClient>(client =>
{
client.BaseAddress = new Uri("https://your-api.azurewebsites.net/");
})
.AddHttpMessageHandler<AuthTokenHandler>();Login UI Examples
XAML + ViewModel
<Button Text="{Binding LoginButtonText}"
Command="{Binding LoginCommand}" />public partial class AuthViewModel : ObservableObject
{
private readonly IAuthService _auth;
[ObservableProperty] string loginButtonText = "Sign In";
[ObservableProperty] string? userName;
public AuthViewModel(IAuthService auth) => _auth = auth;
[RelayCommand]
async Task Login()
{
if (_auth.IsSignedIn)
{
await _auth.SignOutAsync();
UserName = null;
LoginButtonText = "Sign In";
}
else
{
var result = await _auth.SignInAsync();
if (result != null)
{
UserName = result.Account.Username;
LoginButtonText = "Sign Out";
}
}
}
}Blazor Hybrid
@inject IAuthService Auth
<AuthorizeView>
<Authorized>
<span>Hello, @context.User.Identity?.Name</span>
<button @onclick="SignOut">Sign Out</button>
</Authorized>
<NotAuthorized>
<button @onclick="SignIn">Sign In</button>
</NotAuthorized>
</AuthorizeView>
@code {
async Task SignIn() => await Auth.SignInAsync();
async Task SignOut() => await Auth.SignOutAsync();
}MsalAuthenticationStateProvider (for Blazor Hybrid)
public class MsalAuthenticationStateProvider : AuthenticationStateProvider
{
private readonly IAuthService _auth;
public MsalAuthenticationStateProvider(IAuthService auth) => _auth = auth;
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
{
var result = await _auth.AcquireTokenSilentAsync();
if (result == null)
return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
var identity = new ClaimsIdentity(result.ClaimsPrincipal.Claims, "msal");
return new AuthenticationState(new ClaimsPrincipal(identity));
}
public void NotifyAuthStateChanged() =>
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
}Register:
builder.Services.AddAuthorizationCore();
builder.Services.AddScoped<AuthenticationStateProvider, MsalAuthenticationStateProvider>();Entra ID + Aspire Backend
If your MAUI app calls a .NET Aspire-hosted backend API, the API-side JWT Bearer protection is handled by the Entra team's existing skills.
Install the Entra authentication skill (for the API/backend)
mkdir -p .github/skills && cd .github/skills
curl -LO https://aka.ms/msidweb/aspire/entra-id-code-skillThen ask: "Add Entra ID authentication to my Aspire app"
Source: https://github.com/AzureAD/microsoft-identity-web/tree/master/.github/skills