This post is part of the Azure Static Web Apps with Blazor series.
A complete working example is available here: mongeon/code-examples · azure-swa-blazor/04-authentication.
Authentication is often the most painful part to wire up in a project. SWA simplifies things a lot with a built-in system that handles login, logout and user profiles without you having to write authentication code.
How does it work?
SWA exposes a system folder /.auth/ that contains everything you need:
/.auth/login/githubto log in with GitHub/.auth/login/aadto log in with Microsoft Entra ID (formerly Azure AD)/.auth/logoutto log out/.auth/meto get the logged-in user’s info
These two providers (GitHub and Microsoft Entra ID) are available on the free tier, with zero configuration. You don’t need to create an app registration, no client ID, no secret. SWA handles everything.
If you need another provider (Auth0, Google, etc.), you can configure a custom one, but that requires the Standard tier.
Adding login to Blazor
The simplest approach is a link to the login endpoint. In your MainLayout.razor or a navigation component:
<a href="/.auth/login/github">Sign in with GitHub</a>
<a href="/.auth/logout?post_logout_redirect_uri=/">Sign out</a>
The post_logout_redirect_uri parameter controls where the user is redirected after signing out. Without it, they end up on a generic SWA page.
You can also redirect the user to a specific page after login with post_login_redirect_uri:
<a href="/.auth/login/github?post_login_redirect_uri=/dashboard">Sign in</a>
Getting the user in Blazor
The /.auth/me endpoint returns a JSON payload with the logged-in user’s information. To integrate it properly into Blazor, we’ll create a custom AuthenticationStateProvider.
First, the model that represents the /.auth/me response:
public class ClientPrincipal
{
public string IdentityProvider { get; set; } = string.Empty;
public string UserId { get; set; } = string.Empty;
public string UserDetails { get; set; } = string.Empty;
public IEnumerable<string> UserRoles { get; set; } = [];
}
public class AuthResponse
{
public ClientPrincipal? ClientPrincipal { get; set; }
}
Then, the AuthenticationStateProvider that calls /.auth/me:
using System.Security.Claims;
using Microsoft.AspNetCore.Components.Authorization;
public class StaticWebAppsAuthenticationStateProvider : AuthenticationStateProvider
{
private readonly HttpClient _http;
public StaticWebAppsAuthenticationStateProvider(HttpClient http)
{
_http = http;
}
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
{
try
{
var response = await _http.GetFromJsonAsync<AuthResponse>("/.auth/me");
var principal = response?.ClientPrincipal;
if (principal == null)
return new AuthenticationState(new ClaimsPrincipal());
var identity = new ClaimsIdentity(principal.IdentityProvider);
identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, principal.UserId));
identity.AddClaim(new Claim(ClaimTypes.Name, principal.UserDetails));
foreach (var role in principal.UserRoles)
{
identity.AddClaim(new Claim(ClaimTypes.Role, role));
}
return new AuthenticationState(new ClaimsPrincipal(identity));
}
catch
{
return new AuthenticationState(new ClaimsPrincipal());
}
}
}
Register everything in Program.cs:
builder.Services.AddAuthorizationCore();
builder.Services.AddScoped<AuthenticationStateProvider, StaticWebAppsAuthenticationStateProvider>();
And update App.razor to use the CascadingAuthenticationState:
<CascadingAuthenticationState>
<Router AppAssembly="@typeof(Program).Assembly">
<Found Context="routeData">
<AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
</Found>
<NotFound>
<LayoutView Layout="@typeof(MainLayout)">
<p>Page not found.</p>
</LayoutView>
</NotFound>
</Router>
</CascadingAuthenticationState>
After that, you can use Blazor’s standard <AuthorizeView> components in your pages:
<AuthorizeView>
<Authorized>
Hello, @context.User.Identity?.Name!
<a href="/.auth/logout?post_logout_redirect_uri=/">Sign out</a>
</Authorized>
<NotAuthorized>
<a href="/.auth/login/github">Sign in with GitHub</a>
</NotAuthorized>
</AuthorizeView>
Getting the user in the API
On the Azure Functions side, SWA automatically injects an x-ms-client-principal header into requests to /api/*. This header contains the user info as Base64.
Here’s how to decode it in an isolated worker function:
using System.Security.Claims;
using System.Text;
using System.Text.Json;
using Microsoft.Azure.Functions.Worker.Http;
public static class StaticWebAppsAuth
{
public static ClaimsPrincipal ParsePrincipal(HttpRequestData req)
{
if (!req.Headers.TryGetValues("x-ms-client-principal", out var headerValues))
return new ClaimsPrincipal();
var data = headerValues.First();
var decoded = Convert.FromBase64String(data);
var json = Encoding.UTF8.GetString(decoded);
var principal = JsonSerializer.Deserialize<ClientPrincipal>(json,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (principal == null)
return new ClaimsPrincipal();
var identity = new ClaimsIdentity(principal.IdentityProvider);
identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, principal.UserId));
identity.AddClaim(new Claim(ClaimTypes.Name, principal.UserDetails));
foreach (var role in principal.UserRoles.Where(r => r != "anonymous"))
{
identity.AddClaim(new Claim(ClaimTypes.Role, role));
}
return new ClaimsPrincipal(identity);
}
}
You can reuse the ClientPrincipal model by putting it in a Shared project between Client and Api. That’s one of the perks of having everything in C#: the same model works on both sides.
Usage in a function:
[Function("GetProfile")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "profile")] HttpRequestData req)
{
var user = StaticWebAppsAuth.ParsePrincipal(req);
if (!user.Identity?.IsAuthenticated ?? true)
{
return req.CreateResponse(HttpStatusCode.Unauthorized);
}
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new
{
Name = user.Identity.Name,
Roles = user.Claims
.Where(c => c.Type == ClaimTypes.Role)
.Select(c => c.Value)
});
return response;
}
Protecting routes with the config
We saw in article 2 how to protect routes in staticwebapp.config.json. That’s the first layer of security, and it’s enforced by SWA before the request reaches your code:
{
"routes": [
{
"route": "/admin/*",
"allowedRoles": ["authenticated"]
}
],
"responseOverrides": {
"401": {
"statusCode": 302,
"redirect": "/.auth/login/github"
},
"404": {
"rewrite": "/404.html"
}
}
}
That’s fine for blocking access at the proxy level, but always validate the user in your API as well. The config protects routes, the code protects data.
Blocking a provider
By default, GitHub and Microsoft Entra ID are enabled. If you want to force a single provider, block the others by returning a 404 on their login route:
{
"routes": [
{
"route": "/.auth/login/aad",
"statusCode": 404
}
]
}
Custom roles
SWA provides two built-in roles: anonymous (everyone) and authenticated (logged-in users). You can create custom roles and assign them to users through the Azure portal, under Static Web Apps > Role management > Invitations.
You invite a user by their provider identifier (for example their GitHub username) and assign them one or more roles. These roles then show up in the ClientPrincipal and can be used in the config and in code.
In the next article, we’ll explore the swa CLI for local development. It’s the tool that simulates the proxy, the Functions backend and authentication on your machine.
Articles in this series
- What is an Azure Static Web App?
- The staticwebapp.config.json file
- Adding an Azure Functions API
- SWA’s built-in authentication (This article)
- Local development with the swa CLI
- PR preview environments
Happy deploying, and try hitting /.auth/me in your browser to validate that your login works.
This post was written with AI assistance and edited by me.