Implementing Authentication in a .NET 9 MAUI Blazor Hybrid App A shared-project pattern for web and mobile
MAUI Blazor Hybrid lets you share Razor components between a web app and a native mobile app — but hooking up authentication across both targets requires care. This guide walks through a complete, working implementation using a Shared class library, a custom AuthenticationStateProvider, and optional secure storage persistence.
The solution splits across three projects so that authentication logic, components, and pages live once in YourApp.Shared and are consumed by both the web and mobile targets without duplication.
Blazor Web App project — hosts the server-side rendered entry point, wires up Blazor Hub, and registers scoped services for the web runtime.
.NET MAUI Blazor Hybrid project — hosts the native shell on Android, iOS, and Windows, with a BlazorWebView rendering shared Razor pages.
Class library — owns all Razor pages, layout components, services, and the authentication state provider. Both targets reference this project.
Both YourApp.Web and YourApp.Mobile add a project reference to YourApp.Shared and register the shared services in their respective startup files. The authentication flow is identical on both platforms.
Before writing any code, ensure your environment and the Shared project have the correct dependencies.
Environment
- .NET 9 SDK installed
- Visual Studio 2022 (17.7 or later)
- MAUI workload via
dotnet workload install maui - All projects target
net9.0in their.csproj
YourApp.Shared — NuGet packages
Microsoft.AspNetCore.Authorization9.0.0Microsoft.AspNetCore.Components.Authorization9.0.0Microsoft.AspNetCore.Components.Web9.0.0Microsoft.Maui.Essentials9.0.10
Add the following global usings to YourApp.Shared/_Imports.razor so every Razor file in the shared library can use auth and routing primitives without per-file imports:
@using Microsoft.AspNetCore.Authorization @using Microsoft.AspNetCore.Components.Authorization @using YourApp.Shared.Layout @using YourApp.Shared.Pages @using YourApp.Shared.Services
Blazor’s auth pipeline is built around AuthenticationStateProvider. Subclass it in YourApp.Shared/Services/ to give both platforms a single source of truth for the current user identity.
public class CustomAuthenticationStateProvider : AuthenticationStateProvider
{
private ClaimsPrincipal _anonymous = new ClaimsPrincipal(new ClaimsIdentity());
private ClaimsPrincipal _currentUser;
public override Task<AuthenticationState> GetAuthenticationStateAsync()
=> Task.FromResult(new AuthenticationState(_currentUser ?? _anonymous));
public void SignIn(string username)
{
var identity = new ClaimsIdentity(
new[] { new Claim(ClaimTypes.Name, username) }, "apiauth_type");
_currentUser = new ClaimsPrincipal(identity);
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
}
public void SignOut()
{
_currentUser = _anonymous;
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
}
}Register it in both startup files. The pattern is identical — the provider is registered as a scoped service, then aliased so the framework’s AuthenticationStateProvider injection resolves to your custom type:
YourApp.Web — Program.cs
- Call
AddRazorPages()andAddServerSideBlazor() - Register
CustomAuthenticationStateProvideras scoped - Alias
AuthenticationStateProviderto resolve the custom type - Call
AddAuthorizationCore() - Map Blazor Hub and fallback page
YourApp.Mobile — MauiProgram.cs
- Call
AddMauiBlazorWebView() - Register
CustomAuthenticationStateProvideras scoped - Alias
AuthenticationStateProviderto resolve the custom type - Call
AddAuthorizationCore() - Register
UserServiceas scoped
Create a companion UserService in the same Services folder with a simple in-memory credential list. In production, replace AuthenticateUserAsync with a call to your identity API — the interface contract stays the same.
Both components live in YourApp.Shared/Pages/ and are automatically available to web and mobile. Login.razor uses an EditForm with model binding; Logout.razor is a headless page that signs out immediately on initialisation.
@page "/login"
@inject CustomAuthenticationStateProvider AuthStateProvider
@inject UserService UserService
@inject NavigationManager NavigationManager
<EditForm Model="loginModel" OnValidSubmit="HandleLogin">
<InputText @bind-Value="loginModel.Username" />
<InputText type="password" @bind-Value="loginModel.Password" />
<button type="submit">Login</button>
</EditForm>
@code {
private async Task HandleLogin()
{
var user = await UserService.AuthenticateUserAsync(
loginModel.Username, loginModel.Password);
if (user != null)
{
AuthStateProvider.SignIn(user.Username);
NavigationManager.NavigateTo("/");
}
else { ErrorMessage = "Invalid username or password."; }
}
}Logout.razor is even simpler — inject AuthStateProvider, call SignOut() in OnInitialized, then navigate to /. No template content needed beyond the redirect.
Protecting a page requires just one attribute; the router handles the rest via CascadingAuthenticationState and AuthorizeRouteView.
Decorate protected pages
Add @attribute [Authorize] at the top of any Razor page that requires authentication. No other changes needed in the component itself.
Update Routes.razor
Wrap the <Router> in <CascadingAuthenticationState> and replace RouteView with AuthorizeRouteView. Supply a <NotAuthorized> slot that renders a <RedirectToLogin /> component for unauthenticated users.
Create RedirectToLogin.razor
A headless component in YourApp.Shared/Components/ — call NavigationManager.NavigateTo($"/login?returnUrl={Uri.EscapeDataString(NavigationManager.Uri)}") inside OnInitialized.
Reactive NavMenu
In NavMenu.razor subscribe to AuthenticationStateProvider.AuthenticationStateChanged, update isAuthenticated and userName, then call StateHasChanged(). Unsubscribe in IDisposable.Dispose(). Show Login or Logout links conditionally based on isAuthenticated.
The basic provider loses session state on app restart. The extended version uses SecureStorage on mobile and localStorage on web — both via a compile-time platform check.
Mobile (Android / iOS / Windows)
- Read on startup:
SecureStorage.Default.GetAsync(key) - Write on sign-in:
SecureStorage.Default.SetAsync(key, json) - Clear on sign-out:
SecureStorage.Default.Remove(key) - Guarded by
#if ANDROID || IOS || WINDOWScompile directive
Web (Blazor Server)
- Inject
IJSRuntimeinto the provider constructor - Read:
_jsRuntime.InvokeAsync<string>("localStorage.getItem", key) - Write:
_jsRuntime.InvokeVoidAsync("localStorage.setItem", key, json) - Clear:
InvokeVoidAsync("localStorage.removeItem", key)
Beyond persistence, three baseline security measures should be in place before any real users authenticate:
BCrypt.Net or ASP.NET Core’s PasswordHasher<T> to hash credentials at rest.
app.UseHttpsRedirection() and ensure certificates are configured in all deployment environments.
DataAnnotationsValidator on all forms and validate on the server side — never trust client-only validation in a hybrid app.
GeoSaffer covers practical .NET, mobile, and cross-platform development patterns — from architecture decisions to deployment pipelines. Browse more tutorials on the blog.
Explore GeoSaffer →