Implementing Authentication
in a .NET 9 MAUI Blazor Hybrid App (SSR)
The Blazor Hybrid model lets you share Razor components between a Blazor Server web app and a MAUI mobile app — but authentication cannot be shared the same way. SSR imposes strict constraints on cookie auth, while MAUI relies on platform secure storage. This guide walks through both paths in a single three-project solution.
The solution uses three projects. Shared Razor components and services live in YourApp.Shared; the web and mobile apps each register platform-specific implementations of a common auth interface without duplicating any UI logic.
Blazor Server (SSR) app. Handles cookie auth via IHttpContextAccessor and ASP.NET Core middleware.
.NET MAUI Blazor Hybrid app. Uses SecureStorage for persistent, platform-native auth state.
Class library containing the auth interface, all Razor pages, and UserService. Referenced by both other projects.
All four NuGet packages go into YourApp.Shared — neither the web nor the mobile project needs to reference them directly for auth to work.
Environment
- .NET 9 SDK installed
- Visual Studio 2022 v17.12 or later
- MAUI workload:
dotnet workload install maui - All three projects target
net9.0
NuGet — YourApp.Shared
- Microsoft.AspNetCore.Authorization 9.0.0
- Microsoft.AspNetCore.Components.Authorization 9.0.0
- Microsoft.AspNetCore.Components.Web 9.0.0
- Microsoft.Maui.Essentials 9.0.10
Add the following global usings to _Imports.razor in YourApp.Shared so all Razor pages can access auth types 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
The core pattern is a single ICustomAuthenticationService interface with two concrete implementations — one for the server using cookie auth, one for MAUI using SecureStorage. Each project’s DI container registers its own version.
Define the interface in YourApp.Shared/Services/ICustomAuthenticationService.cs:
public interface ICustomAuthenticationService
{
Task SignInAsync(string username);
Task SignOutAsync();
Task<ClaimsPrincipal> GetCurrentUserAsync();
}
Blazor Server (Web)
- Inject
IHttpContextAccessorinto the service SignInAsynccallsHttpContext.SignInAsync("Cookies", principal)SignOutAsynccallsHttpContext.SignOutAsync("Cookies")- Provide a
ServerAuthenticationStateProviderthat readsHttpContext.User - Register cookie middleware and
AddHttpContextAccessorinProgram.cs
MAUI (Mobile)
- No HTTP context —
IHttpContextAccessoris unavailable in MAUI SignInAsyncwrites username toSecureStorageand callsNotifyAuthenticationStateChangedSignOutAsyncremoves the key and resets to an anonymous principal- Register
CustomAuthenticationStateProvideras both its concrete type and asAuthenticationStateProvider - State survives app restarts via
SecureStorage.Default.GetAsync
In YourApp.Web/Program.cs, configure cookie authentication and register the server-specific services:
builder.Services.AddAuthentication("Cookies")
.AddCookie("Cookies", options => { options.LoginPath = "/login"; });
builder.Services.AddAuthorizationCore();
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ICustomAuthenticationService, ServerAuthenticationService>();
builder.Services.AddScoped<AuthenticationStateProvider, ServerAuthenticationStateProvider>();
builder.Services.AddScoped<UserService>();
In YourApp.Mobile/MauiProgram.cs, register the MAUI-specific auth services:
builder.Services.AddAuthorizationCore();
builder.Services.AddScoped<CustomAuthenticationStateProvider>();
builder.Services.AddScoped<AuthenticationStateProvider>(provider =>
provider.GetRequiredService<CustomAuthenticationStateProvider>());
builder.Services.AddScoped<ICustomAuthenticationService, CustomAuthenticationService>();
builder.Services.AddScoped<UserService>();
These three components live entirely in YourApp.Shared and are consumed by both web and mobile without modification. The user store is intentionally minimal — replace plain-text passwords with a hashed database before going anywhere near production.
Create YourApp.Shared/Services/UserService.cs. It holds a hardcoded list of UserCredential objects with Username, Password, and Role. AuthenticateUserAsync does a LINQ match — plaintext strings for now, swap in IPasswordHasher<T> before shipping.
Route: @page "/login". Inject ICustomAuthenticationService, UserService, and NavigationManager. On valid submit, call AuthenticateUserAsync; on success call AuthService.SignInAsync(user.Username) and navigate to /. On failure, render an inline error string — no page reload needed.
Route: @page "/logout". Override OnInitializedAsync to call AuthService.SignOutAsync() and then NavigationManager.NavigateTo("/"). No user interaction required — navigating to the route triggers the logout immediately.
Three files need updating to wire auth into the app’s routing and navigation UI. All three live in or reference YourApp.Shared.
@attribute [Authorize]
Add the [Authorize] attribute to any Razor page that requires a signed-in user. Inside, call AuthService.GetCurrentUserAsync() in OnInitializedAsync to retrieve the current identity and display user.Identity?.Name.
CascadingAuthenticationState
Replace the default <Router> with <CascadingAuthenticationState> wrapping <AuthorizeRouteView>. The <NotAuthorized> template should check context.User.Identity.IsAuthenticated to distinguish “not signed in” from “signed in but lacking permission”.
Inject AuthenticationStateProvider and implement IDisposable. In OnInitializedAsync, fetch the current auth state and subscribe to AuthenticationStateChanged. Render Login or Logout links based on isAuthenticated. Unsubscribe in Dispose() — the event holds a strong reference and will leak memory if you skip this.
The implementation above is a functional skeleton — suitable for local development and prototyping. Four areas must be addressed before it touches real users.
IPasswordHasher<T>. Never compare passwords as raw strings in production.
app.UseHttpsRedirection() and set SecurePolicy = CookieSecurePolicy.Always. Cookie auth over HTTP exposes session tokens in transit.
DataAnnotations to LoginModel and use <DataAnnotationsValidator> in the form. Validate server-side too — Blazor Server renders on the server, but don’t rely solely on client-side checks.
Microsoft.AspNetCore.Authentication.OpenIdConnect or Microsoft Identity Web.
GeoSaffer covers .NET architecture, mobile development, and developer tooling. Browse more articles on cross-platform Blazor, MAUI patterns, and backend integration.
More articles on GeoSaffer →