Are you absolutely sure your Blazor app isn’t quietly showing sensitive UI to the wrong users? I once shipped a page where an “Admin” button was just hidden with an if
– until someone pressed F12 and called the endpoint directly. Ouch. In this post, I’ll show you how to wire authentication and authorization the right way – roles, policies, handlers, and secure UI – so your Blazor app stays locked down even when someone pokes it with dev tools.
Auth mental model in Blazor (quick map)
Blazor Server
- Connection: persistent SignalR circuit.
- Authentication: typically cookies (ASP.NET Core Identity).
- Authorization: same ASP.NET Core policy system as MVC/Razor Pages.
Blazor WebAssembly (hosted)
- UI runs in the browser; API runs on the server.
- Authentication: OIDC (Azure AD/B2C/IdentityServer/etc.) or custom JWT against your API.
- Authorization:
AddAuthorizationCore()
in the client; server still enforces with ASP.NET Core policies.
Claims vs roles vs policies
- Claim = a fact:
department = Sales
,age = 21
. - Role = a label:
Admin
,Editor
. - Policy = a rule built from claims/roles/logic (e.g.,
MustBeEditor && Age >= 18
).
Project setup – Blazor Server + Identity (*.NET 8 LTS)
1) Add packages
# If you started from an empty Server project
dotnet add package Microsoft.AspNetCore.Identity.EntityFrameworkCore
dotnet add package Microsoft.EntityFrameworkCore.SqlServer
dotnet add package Microsoft.EntityFrameworkCore.Tools
2) Create the Identity DbContext
// Data/ApplicationDbContext.cs
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
namespace MyApp.Data;
public class ApplicationDbContext : IdentityDbContext<IdentityUser, IdentityRole, string>
{
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options) { }
}
3) Wire services in Program.cs
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using MyApp.Data;
var builder = WebApplication.CreateBuilder(args);
// Data
builder.Services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
// Identity (cookie-based auth)
builder.Services
.AddIdentityCore<IdentityUser>(options =>
{
options.Password.RequiredLength = 8;
options.Password.RequireNonAlphanumeric = false;
options.User.RequireUniqueEmail = true;
options.Lockout.MaxFailedAccessAttempts = 5;
})
.AddRoles<IdentityRole>()
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddSignInManager()
.AddDefaultTokenProviders();
builder.Services.AddAuthentication(options =>
{
options.DefaultScheme = IdentityConstants.ApplicationScheme;
})
.AddIdentityCookies();
builder.Services.AddAuthorizationBuilder()
.AddPolicy("RequireAdmin", policy => policy.RequireRole("Admin"))
.AddPolicy("CanPublish", policy => policy.RequireClaim("permission", "publish"));
// Blazor Server (\*.NET 8+)
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
var app = builder.Build();
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode();
await app.Services.SeedIdentityAsync(); // create roles/users on first run
await app.RunAsync();
4) Seed roles and the initial admin
// Infrastructure/IdentitySeeder.cs
using Microsoft.AspNetCore.Identity;
namespace MyApp.Infrastructure;
public static class IdentitySeeder
{
public static async Task SeedIdentityAsync(this IServiceProvider services)
{
using var scope = services.CreateScope();
var roleManager = scope.ServiceProvider.GetRequiredService<RoleManager<IdentityRole>>();
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<IdentityUser>>();
string[] roles = ["Admin", "Editor", "Reader"];
foreach (var r in roles)
if (!await roleManager.RoleExistsAsync(r))
await roleManager.CreateAsync(new IdentityRole(r));
var adminEmail = "admin@demo.local";
var admin = await userManager.FindByEmailAsync(adminEmail);
if (admin is null)
{
admin = new IdentityUser { UserName = adminEmail, Email = adminEmail, EmailConfirmed = true };
await userManager.CreateAsync(admin, "Passw0rd!123");
await userManager.AddToRoleAsync(admin, "Admin");
await userManager.AddClaimsAsync(admin, new[]
{
new System.Security.Claims.Claim("permission", "publish"),
new System.Security.Claims.Claim("department", "Editorial")
});
}
}
}
5) Add sign-in UI quickly
For a production app, scaffold Identity pages or use your own login form that calls SignInManager
. Here’s a minimal login endpoint you can drop into a Razor page or API for admin-only environments:
// Minimal login (for demo). Prefer the full Identity UI in real apps.
app.MapPost("/auth/login", async (SignInManager<IdentityUser> signIn, UserManager<IdentityUser> users, LoginDto dto) =>
{
var user = await users.FindByEmailAsync(dto.Email);
if (user is null) return Results.Unauthorized();
var result = await signIn.PasswordSignInAsync(user, dto.Password, dto.RememberMe, lockoutOnFailure: true);
return result.Succeeded ? Results.Ok() : Results.Unauthorized();
});
public record LoginDto(string Email, string Password, bool RememberMe);
Role-based authorization in components and routes
Authorize at the page level
@page "/admin"
@attribute [Authorize(Roles = "Admin")]
<h1>Admin Console</h1>
Show/hide UI with AuthorizeView
<AuthorizeView Roles="Admin">
<Authorized>
<button class="btn btn-danger" @onclick="DeleteArticle">Delete</button>
</Authorized>
<NotAuthorized>
<span>You do not have permission to delete.</span>
</NotAuthorized>
</AuthorizeView>
Nav menu trimming
<AuthorizeView Roles="Admin">
<a href="/admin" class="nav-link">⚙ Admin</a>
</AuthorizeView>
Tip: UI checks are not security boundaries. Always protect the server endpoint with
[Authorize]
or policies.
Policy-based authorization (claims + logic)
Policies are more expressive than plain roles. Example: only members of Editorial department with the publish
permission can publish.
Define policies
builder.Services.AddAuthorizationBuilder()
.AddPolicy("Publisher", policy =>
policy.RequireClaim("department", "Editorial")
.RequireClaim("permission", "publish"));
Use policies in components
@attribute [Authorize(Policy = "Publisher")]
<h3>Publish Article</h3>
Custom requirement & handler (resource-based rule: only the owner or admin can edit)
using Microsoft.AspNetCore.Authorization;
public sealed class CanEditArticleRequirement : IAuthorizationRequirement { }
public sealed class CanEditArticleHandler : AuthorizationHandler<CanEditArticleRequirement, Article>
{
protected override Task HandleRequirementAsync(AuthorizationHandlerContext context,
CanEditArticleRequirement requirement, Article resource)
{
var userId = context.User.FindFirst("sub")?.Value
?? context.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
if (context.User.IsInRole("Admin") || resource.AuthorId == userId)
{
context.Succeed(requirement);
}
return Task.CompletedTask;
}
}
public record Article(string Id, string Title, string AuthorId);
Register the handler
builder.Services.AddSingleton<IAuthorizationHandler, CanEditArticleHandler>();
Use it in code-behind
@code {
[Parameter] public Article? Current { get; set; }
[Inject] IAuthorizationService Authz { get; set; } = default!;
[CascadingParameter] Task<AuthenticationState> AuthStateTask { get; set; } = default!;
bool canEdit;
protected override async Task OnParametersSetAsync()
{
var user = (await AuthStateTask).User;
var result = await Authz.AuthorizeAsync(user, Current!, new CanEditArticleRequirement());
canEdit = result.Succeeded;
}
}
Reading the current user (AuthenticationStateProvider
)
@inject AuthenticationStateProvider Auth
<p>
Hello, @displayName
</p>
@code {
private string displayName = "anonymous";
protected override async Task OnInitializedAsync()
{
var state = await Auth.GetAuthenticationStateAsync();
displayName = state.User.Identity?.Name ?? "anonymous";
}
}
Blazor Server automatically provides
CascadingAuthenticationState
when you use the default hosting pattern. In WASM, callAddAuthorizationCore()
and supply an auth provider (OIDC or custom).
Protecting server endpoints (minimal APIs / controllers)
Whatever the UI shows, always secure the API/server endpoints.
var api = app.MapGroup("/api/articles");
api.MapGet("{id}", (string id) => /* ... */)
.RequireAuthorization(); // any authenticated user
api.MapDelete("{id}", (string id) => /* ... */)
.RequireAuthorization("RequireAdmin");
api.MapPost("publish/{id}", (string id) => /* ... */)
.RequireAuthorization("Publisher");
// Optionally enforce auth by default on APIs
app.MapGroup("/api").RequireAuthorization();
Pro move: set a fallback policy so anonymous access must be explicit.
builder.Services.AddAuthorization(options =>
{
options.FallbackPolicy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.Build();
});
Blazor WebAssembly (hosted): OIDC/JWT in practice
For WASM, the client shows UI and carries an access token to call your API. Two common setups:
- OIDC provider (Azure AD/B2C, Auth0, etc.): Client gets tokens via OIDC; API validates JWTs.
- Local Identity + JWT: Your server issues JWTs after login; client stores them (in memory or secure storage) and attaches them to API calls.
Client (WASM) registration
// Program.cs (Client)
using Microsoft.AspNetCore.Components.Authorization;
var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.Services.AddAuthorizationCore();
// If using OIDC (example skeleton)
// builder.Services.AddOidcAuthentication(options =>
// {
// builder.Configuration.Bind("Oidc", options.ProviderOptions);
// });
// Attach tokens to your API calls
builder.Services.AddHttpClient("api", client => client.BaseAddress = new Uri(builder.HostEnvironment.BaseAddress))
.AddHttpMessageHandler(sp =>
{
// Use a handler that adds Authorization: Bearer <token>
return sp.GetRequiredService<AuthorizationMessageHandler>()
.ConfigureHandler(authorizedUrls: [builder.HostEnvironment.BaseAddress + "api/"]);
});
await builder.Build().RunAsync();
Server (API) validating JWTs
// Program.cs (Server API if issuing/accepting JWT)
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using System.Text;
var key = builder.Configuration["Jwt:Key"]!; // store safely
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = builder.Configuration["Jwt:Issuer"],
ValidAudience = builder.Configuration["Jwt:Audience"],
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(key))
};
});
builder.Services.AddAuthorization();
app.UseAuthentication();
app.UseAuthorization();
Remote login UI in WASM
<!-- App.razor (WASM) -->
<CascadingAuthenticationState>
<Router AppAssembly="typeof(App).Assembly">
<Found Context="routeData">
<RouteView RouteData="routeData" DefaultLayout="typeof(MainLayout)" />
</Found>
<NotFound>
<p>Sorry, there’s nothing at this address.</p>
</NotFound>
</Router>
<!-- If using OIDC template -->
<!-- <RemoteAuthenticatorView /> -->
</CascadingAuthenticationState>
In WASM, never trust client-only checks. The API must enforce policies/roles the same way the server does in Blazor Server.
Blazor Server specifics: cookies, SignalR, revalidation
- Cookies: ensure
Secure
,HttpOnly
, reasonable expiration.
builder.Services.ConfigureApplicationCookie(o =>
{
o.Cookie.HttpOnly = true;
o.Cookie.SecurePolicy = CookieSecurePolicy.Always;
o.SlidingExpiration = true;
o.ExpireTimeSpan = TimeSpan.FromMinutes(60);
o.LoginPath = "/account/login";
});
- Revalidating auth: when roles/claims change, revalidate.
builder.Services.AddScoped<RevalidatingIdentityAuthenticationStateProvider<IdentityUser>>();
- Antiforgery: for any traditional POST endpoints used alongside Blazor, keep antiforgery enabled.
- SignalR: authorize hubs or hub methods as needed with
[Authorize]
or policies.
Testing authorization (bUnit + integration)
bUnit: verify UI reacts to roles
using Bunit;
using Bunit.TestDoubles;
using Xunit;
public class AdminButtonTests
{
[Fact]
public void AdminButton_Shown_For_Admins()
{
using var ctx = new TestContext();
var auth = ctx.AddTestAuthorization();
auth.SetAuthorized("admin@demo.local");
auth.SetRoles("Admin");
var cut = ctx.RenderComponent<AdminPanel>();
cut.MarkupMatches("*Delete*");
}
}
Integration: endpoint truly locked down
// Pseudocode skeleton with WebApplicationFactory
var client = factory.CreateClient();
var resp = await client.DeleteAsync("/api/articles/42");
Assert.Equal(HttpStatusCode.Unauthorized, resp.StatusCode);
Security checklist (battle-tested)
- Enforce HTTPS, HSTS.
- Set a fallback policy to require authenticated users by default.
- Hide admin UI and guard endpoints with policies.
- Keep Identity tokens & cookies short-lived; enable sliding expiration if needed.
- Consider Two-Factor Auth for admins.
- Log authorization failures; avoid leaking details in error messages.
- Avoid storing JWTs in
localStorage
if possible (prefer in-memory + refresh flows). - Review CORS rules for WASM APIs (be explicit).
- Protect file uploads; validate sizes and content types.
- Regularly update packages and rotate secrets/keys.
FAQ: Blazor Authorization Essentials
Roles are simple and great for coarse permissions (Admin
). Policies scale better because you can compose claims/logic. I usually expose roles to product folks and implement policies under the hood.
Yes – users deserve clear UX. Hide actions they can’t perform. But treat UI checks as courtesy only – the server is the real gatekeeper.
Prefer in-memory (or browser session storage) and short lifetimes. If you must persist, consider refresh tokens with secure patterns and strict scopes.
Absolutely. Cookies for Blazor Server UI; JWTs for external/mobile clients. Just make sure each endpoint knows which scheme to expect.
Use resource-based authorization with IAuthorizationService
and custom handlers that evaluate the current resource and tenant claims.
Conclusion: Ship features fast, but lock the doors
Security isn’t a sprint you do once; it’s a guardrail you install and forget – until it saves you. With Identity for sign-in, policies for fine-grained control, and server-side enforcement on every endpoint, your Blazor app becomes much harder to break. Try the snippets above, add one policy today, and watch how your codebase stays tidy and safe.
Which permission in your app would benefit most from a custom policy or handler? Drop a comment – I’m happy to sketch a policy with you.