Technology: Blazor Web App (auto render mode), ASP.NET Core, Entra ID Level: Intermediate | Estimated duration: ~3h30
Table of Contents
- Blazor Render Modes and Security Impact
- User Management — Blazor Identity UI
- AuthenticationStateProvider In Depth
- Blazor Authorization Components
- Integration with Microsoft Entra ID (OpenID Connect)
- Protecting Local APIs with Cookies
- Protecting Remote APIs with Access Tokens
- Authorization Policies — RBAC vs ABAC
- Common Pitfalls and Anti-Patterns
- Practical Exercises
- Technical Glossary
1. Blazor Render Modes and Security Impact
1.1 Overview of the 4 Blazor Flavors
flowchart TD
Blazor[Blazor Ecosystem] --> WASM[Blazor WebAssembly]
Blazor --> Server[Blazor Server]
Blazor --> Hybrid[Blazor Hybrid]
Blazor --> WebApp[Blazor Web App\n.NET 8+]
WASM --> WASM1[Runs in the browser]
WASM --> WASM2[.NET runtime via WebAssembly]
WASM --> WASM3[Client-side security = illusory]
Server --> Server1[Runs on the server]
Server --> Server2[UI streamed via SignalR]
Server --> Server3[Real security server-side]
Hybrid --> Hybrid1[Native iOS/Android/Windows apps]
Hybrid --> Hybrid2[.NET MAUI + Blazor]
Hybrid --> Hybrid3[Native code = secure]
WebApp --> WebApp1[Combines Server + WASM]
WebApp --> WebApp2[Auto mode — recommended]
WebApp --> WebApp3[Hybrid security depending on active mode]
style WASM fill:#FFE4E4
style Server fill:#E4FFE4
style Hybrid fill:#E4E4FF
style WebApp fill:#FFFDE4
1.2 Fundamental Security Principle of Blazor
⚠️ CARDINAL RULE: What runs client-side (WASM) CANNOT be secured. Any client-side check is cosmetic — it improves user experience but protects nothing. Real security ALWAYS applies server-side or at the API.
This principle stems from the very nature of WebAssembly: compiled code runs in the browser where the user has full control. With browser DevTools or tools like wasm-dis, it is possible to inspect and modify WASM code behavior.
Analogy: Imagine writing building access rules on a sticky note you give to the visitor. Obviously, the visitor can modify this note. Real security is the guard at the entrance who checks badges — that’s your server API.
1.3 Blazor Web App — Auto Mode
The Blazor Web App with Interactive Auto render mode is the recommended mode for new applications:
sequenceDiagram
participant User as User
participant Browser as Browser
participant Server as Blazor Server
participant SignalR as SignalR Hub
Note over User, Server: 1st visit (WASM bundle not yet cached)
User->>Browser: Navigate to /page
Browser->>Server: GET /page (with auth cookie)
Server->>Browser: Pre-rendered HTML (SSR)
Server->>SignalR: Establish SignalR connection
SignalR->>Browser: Interactive Server mode active
Note over Browser: Reactive UI via SignalR
Note over User, Server: Subsequent visits (WASM bundle cached)
User->>Browser: Navigate to /page
Browser->>Server: GET /page (with auth cookie)
Server->>Browser: Pre-rendered HTML + WASM bundle
Browser->>Browser: WASM runtime starts
Note over Browser: Interactive WebAssembly mode active
Note over Browser: SignalR no longer used
Security impact for each phase:
| Phase | Active mode | HttpContext | Auto cookie | Security strategy |
|---|---|---|---|---|
| 1st visit | Interactive Server (SignalR) | ⚠️ Available but unreliable | ✅ Yes (initial) | Server AuthStateProvider |
| Subsequent visits | Interactive WebAssembly | ❌ Not available | ⚠️ To configure | Client AuthStateProvider + BFF |
| Prerendering | Always on server | ✅ Available | ✅ Yes | HttpContext.User reliable |
1.4 Render Mode per Component
@* Static SSR — No interactivity, HttpContext.User always available *@
@* (No rendermode attribute = SSR by default) *@
@* Interactive Server — SignalR, HttpContext unreliable when interactive *@
@rendermode InteractiveServer
@* Interactive WebAssembly — Pure WASM, HttpContext absent *@
@rendermode InteractiveWebAssembly
@* Interactive Auto — Server first, switches to WASM *@
@rendermode InteractiveAuto
2. User Management — Blazor Identity UI
2.1 Two Approaches for IAM (Identity and Access Management)
flowchart LR
App[Blazor Application] --> ChoiceIAM{IAM Approach}
ChoiceIAM --> Local[Local management\nBlazor Identity UI]
ChoiceIAM --> External[External delegation\nOpenID Connect IDP]
Local --> LocalPros["✅ Self-contained\n✅ No external dependency\n✅ Full control"]
Local --> LocalCons["❌ Store passwords\n❌ Manage MFA yourself\n❌ No native SSO\n❌ Separate management per app"]
External --> ExtPros["✅ Centralized credentials\n✅ Built-in MFA\n✅ Native SSO\n✅ Proven secure protocols\n✅ Entra ID, Okta, Auth0..."]
External --> ExtCons["❌ External dependency\n❌ Potential cost\n❌ Initial configuration"]
style Local fill:#FFE4B5
style External fill:#B5FFB5
Recommendation: For enterprise applications or SaaS, the external approach with an identity provider is almost always preferable. ASP.NET Core Identity is ideal for standalone applications with a limited number of users where the complexity of an external provider is not justified.
2.2 Blazor Identity UI — Architecture
classDiagram
class BlazorIdentityUI {
<<Pre-built Razor Components>>
+Login.razor
+Register.razor
+Logout.razor
+ForgotPassword.razor
+ResetPassword.razor
+ConfirmEmail.razor
+EnableAuthenticator.razor
+ExternalLogin.razor
}
class ASPNETCoreIdentity {
<<User management framework>>
+UserManager~TUser~ userManager
+SignInManager~TUser~ signInManager
+RoleManager~TRole~ roleManager
+IPasswordHasher~TUser~ passwordHasher
+PasswordSignInAsync()
+CreateAsync()
+AddToRoleAsync()
+GetUserAsync()
}
class EntityFrameworkCore {
<<ORM — Persistence>>
+IdentityDbContext context
+SQLite / SQL Server
+Migrations
+IdentityUser table
+IdentityRole table
}
class ApplicationDbContext {
+DbSet~BlazorAuthNZDemoUser~ Users
+OnModelCreating()
}
class BlazorAuthNZDemoUser {
+Inherits IdentityUser
+string? DisplayName
+string? Country
}
BlazorIdentityUI --> ASPNETCoreIdentity : calls services
ASPNETCoreIdentity --> EntityFrameworkCore : persists via EF Core
EntityFrameworkCore --> ApplicationDbContext : uses
ApplicationDbContext --> BlazorAuthNZDemoUser : manages
Critical point: Even if Blazor Identity UI Razor components may look like normal Blazor components, all C# code that interacts with ASP.NET Core Identity (SignInManager, UserManager) must run on the server. This is intentional and non-negotiable — authentication logic must never run in the browser.
2.3 Enabling Blazor Identity UI
Method 1 — New project:
Visual Studio → New Project → Blazor Web App
→ Authentication type: "Individual Accounts"
→ Identity UI is automatically scaffolded in the host project
Method 2 — Existing project:
Right-click server project → Add → New Scaffolded Item
→ Identity → Blazor Identity
→ Configure:
DbContext class: BlazorAuthNZDemoContext (+ button to create)
Database provider: SQLite (compatible Mac/Linux) or SQL Server
User class: BlazorAuthNZDemoUser (+ button to create)
→ Add
Result of scaffolding:
BlazorAuthNZDemo/
├── Components/Account/
│ ├── Pages/
│ │ ├── Login.razor
│ │ ├── Register.razor
│ │ ├── Logout.razor
│ │ ├── ForgotPassword.razor
│ │ ├── ResetPassword.razor
│ │ ├── ConfirmEmail.razor
│ │ └── Manage/
│ │ ├── Index.razor
│ │ ├── ChangePassword.razor
│ │ └── EnableAuthenticator.razor
│ └── Shared/
│ ├── RedirectToLogin.razor
│ └── ExternalLoginPicker.razor
├── Data/
│ ├── ApplicationDbContext.cs
│ └── BlazorAuthNZDemoUser.cs
└── Migrations/
└── 00000000000000_CreateIdentitySchema.cs
2.4 Program.cs Configuration (Identity)
// Program.cs — Complete ASP.NET Core Identity + Blazor configuration
var builder = WebApplication.CreateBuilder(args);
// ═══════════════════════════════════════════════════════════════
// 1. RAZOR COMPONENTS — Support for all render modes
// ═══════════════════════════════════════════════════════════════
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents()
.AddInteractiveWebAssemblyComponents();
// ═══════════════════════════════════════════════════════════════
// 2. IDENTITY DATABASE
// ═══════════════════════════════════════════════════════════════
var connectionString = builder.Configuration
.GetConnectionString("DefaultConnection")
?? throw new InvalidOperationException("Connection string 'DefaultConnection' not found.");
builder.Services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlite(connectionString));
builder.Services.AddDatabaseDeveloperPageExceptionFilter();
// ═══════════════════════════════════════════════════════════════
// 3. ASP.NET CORE IDENTITY — User management
// ═══════════════════════════════════════════════════════════════
builder.Services.AddIdentityCore<BlazorAuthNZDemoUser>(options =>
{
options.Password.RequiredLength = 6;
options.Password.RequireNonAlphanumeric = false;
options.Password.RequireDigit = true;
options.Password.RequireUppercase = true;
options.SignIn.RequireConfirmedAccount = true;
options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(5);
options.Lockout.MaxFailedAccessAttempts = 5;
options.Lockout.AllowedForNewUsers = true;
})
.AddRoles<IdentityRole>()
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddSignInManager()
.AddDefaultTokenProviders();
// ═══════════════════════════════════════════════════════════════
// 4. COOKIE AUTHENTICATION
// ═══════════════════════════════════════════════════════════════
builder.Services.AddAuthentication(options =>
{
options.DefaultScheme = IdentityConstants.ApplicationScheme;
options.DefaultSignInScheme = IdentityConstants.ExternalScheme;
})
.AddIdentityCookies();
// ═══════════════════════════════════════════════════════════════
// 5. AUTHENTICATION STATE — Propagation to Blazor components
// ═══════════════════════════════════════════════════════════════
builder.Services.AddCascadingAuthenticationState();
builder.Services.AddScoped<AuthenticationStateProvider,
PersistingRevalidatingAuthenticationStateProvider>();
builder.Services.AddScoped<IdentityUserAccessor>();
builder.Services.AddScoped<IdentityRedirectManager>();
// ═══════════════════════════════════════════════════════════════
// 6. AUTHORIZATION
// ═══════════════════════════════════════════════════════════════
builder.Services.AddAuthorization();
// ═══════════════════════════════════════════════════════════════
// 7. BUSINESS SERVICES
// ═══════════════════════════════════════════════════════════════
builder.Services.AddScoped<BandsRepository>();
var app = builder.Build();
// ═══════════════════════════════════════════════════════════════
// MIDDLEWARE PIPELINE (CRITICAL ORDER!)
// ═══════════════════════════════════════════════════════════════
if (app.Environment.IsDevelopment())
{
app.UseWebAssemblyDebugging();
app.UseMigrationsEndPoint();
}
else
{
app.UseExceptionHandler("/Error", createScopeForErrors: true);
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
// IMPORTANT: UseAuthentication BEFORE UseAuthorization
app.UseAuthentication();
app.UseAuthorization();
app.UseAntiforgery();
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode()
.AddInteractiveWebAssemblyRenderMode()
.AddAdditionalAssemblies(typeof(BlazorAuthNZDemo.Client._Imports).Assembly);
// Identity endpoints (login, logout, register, manage, etc.)
app.MapAdditionalIdentityEndpoints();
// Local API
app.MapGet("/localapi/bands", async (BandsRepository repo) =>
Results.Ok(await repo.GetBandsAsync()))
.RequireAuthorization();
app.Run();
2.5 Protecting Pages with [Authorize]
@* Pages/InteractiveServerMode.razor — Server mode *@
@page "/interactive-server"
@rendermode InteractiveServer
@attribute [Authorize]
@using Microsoft.AspNetCore.Authorization
@inject BandsRepository BandsRepo
<PageTitle>Interactive Server Mode</PageTitle>
<h1>Protected page — Server mode</h1>
@code {
private List<Band>? _bands;
protected override async Task OnInitializedAsync()
{
// On server: direct repository access, no HTTP
_bands = await BandsRepo.GetBandsAsync();
}
}
@* Pages/InteractiveAutoMode.razor (client-side) — Auto mode *@
@page "/interactive-auto"
@rendermode InteractiveAuto
@attribute [Authorize]
@inject HttpClient HttpClient
@code {
private List<Band>? _localBands;
protected override async Task OnInitializedAsync()
{
await LoadLocalBandsAsync();
}
private async Task LoadLocalBandsAsync()
{
// WASM side: must go through HTTP request with cookie
var request = new HttpRequestMessage(HttpMethod.Get, "/localapi/bands");
request.SetBrowserRequestCredentials(BrowserRequestCredentials.Include);
var response = await HttpClient.SendAsync(request);
if (response.IsSuccessStatusCode)
_localBands = await response.Content.ReadFromJsonAsync<List<Band>>();
}
}
3. AuthenticationStateProvider In Depth
3.1 Definition and Hierarchy
classDiagram
class AuthenticationStateProvider {
<<Abstract Base Class>>
+GetAuthenticationStateAsync() Task~AuthenticationState~
+AuthenticationStateChanged event
#NotifyAuthenticationStateChanged(Task~AuthenticationState~)
}
class AuthenticationState {
+ClaimsPrincipal User
+AuthenticationState(ClaimsPrincipal user)
}
class PersistingRevalidatingAuthenticationStateProvider {
<<Server — with Identity>>
-UserManager~TUser~ userManager
-IServiceScopeFactory scopeFactory
+GetAuthenticationStateAsync()
#ValidateAuthenticationStateAsync()
-PersistUserInfoToPage()
}
class PersistentAuthenticationStateProvider {
<<WASM Client>>
-IEnumerable~Claim~ claims
+GetAuthenticationStateAsync()
-ReadUserInfoFromPage()
}
class ServerAuthenticationStateProvider {
<<Server — with OIDC>>
-IHttpContextAccessor accessor
+GetAuthenticationStateAsync()
-PersistUserInfoToPage()
}
AuthenticationStateProvider --> AuthenticationState : returns
PersistingRevalidatingAuthenticationStateProvider --|> AuthenticationStateProvider
PersistentAuthenticationStateProvider --|> AuthenticationStateProvider
ServerAuthenticationStateProvider --|> AuthenticationStateProvider
3.2 PersistingRevalidatingAuthenticationStateProvider (Server, with Identity)
This provider does three crucial things:
- Provides authentication state to server components by reading
HttpContext.User - Persists user information in the HTML page (as JSON) so the WASM client can read it
- Periodically revalidates the security stamp to detect session revocations
// Services/PersistingRevalidatingAuthenticationStateProvider.cs
public class PersistingRevalidatingAuthenticationStateProvider
: RevalidatingServerAuthenticationStateProvider
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly PersistentComponentState _state;
private readonly PersistingComponentStateSubscription _subscription;
private Task<AuthenticationState>? _authenticationStateTask;
public PersistingRevalidatingAuthenticationStateProvider(
ILoggerFactory loggerFactory,
IServiceScopeFactory serviceScopeFactory,
PersistentComponentState persistentComponentState)
: base(loggerFactory)
{
_scopeFactory = serviceScopeFactory;
_state = persistentComponentState;
AuthenticationStateChanged += OnAuthenticationStateChanged;
_subscription = _state.RegisterOnPersisting(OnPersistingAsync, RenderMode.InteractiveWebAssembly);
}
// Revalidates the security stamp every 30 minutes during a SignalR session
protected override TimeSpan RevalidationInterval => TimeSpan.FromMinutes(30);
protected override async Task<bool> ValidateAuthenticationStateAsync(
AuthenticationState authenticationState,
CancellationToken cancellationToken)
{
await using var scope = _scopeFactory.CreateAsyncScope();
var userManager = scope.ServiceProvider
.GetRequiredService<UserManager<BlazorAuthNZDemoUser>>();
return await ValidateSecurityStampAsync(userManager, authenticationState.User);
}
private static async Task<bool> ValidateSecurityStampAsync(
UserManager<BlazorAuthNZDemoUser> userManager,
ClaimsPrincipal principal)
{
var user = await userManager.GetUserAsync(principal);
if (user is null) return false;
if (!userManager.SupportsUserSecurityStamp) return true;
var principalStamp = principal.FindFirstValue(
new ClaimsIdentityOptions().SecurityStampClaimType);
var userStamp = await userManager.GetSecurityStampAsync(user);
return principalStamp == userStamp;
}
// Persists user info in the HTML page for the WASM client
private async Task OnPersistingAsync()
{
if (_authenticationStateTask is null) return;
var authenticationState = await _authenticationStateTask;
var principal = authenticationState.User;
if (principal.Identity?.IsAuthenticated == true)
{
var userId = principal.FindFirst("sub")?.Value;
var name = principal.FindFirst("name")?.Value ??
principal.FindFirst(ClaimTypes.Name)?.Value;
if (userId != null && name != null)
{
_state.PersistAsJson("UserInfo", new UserInfo
{
UserId = userId,
Name = name,
Email = principal.FindFirst(ClaimTypes.Email)?.Value ?? string.Empty
});
}
}
}
private void OnAuthenticationStateChanged(Task<AuthenticationState> task)
{
_authenticationStateTask = task;
}
protected override void Dispose(bool disposing)
{
_subscription.Dispose();
AuthenticationStateChanged -= OnAuthenticationStateChanged;
base.Dispose(disposing);
}
}
3.3 PersistentAuthenticationStateProvider (WASM Client)
// Services/PersistentAuthenticationStateProvider.cs
// (Place in the BlazorAuthNZDemo.Client project)
public class PersistentAuthenticationStateProvider : AuthenticationStateProvider
{
private static readonly Task<AuthenticationState> _defaultUnauthenticatedTask =
Task.FromResult(new AuthenticationState(
new ClaimsPrincipal(new ClaimsIdentity())));
private readonly Task<AuthenticationState> _authenticationStateTask = _defaultUnauthenticatedTask;
public PersistentAuthenticationStateProvider(PersistentComponentState state)
{
if (!state.TryTakeFromJson<UserInfo>("UserInfo", out var userInfo)
|| userInfo is null)
{
return; // No persisted user = anonymous
}
// Reconstruct the ClaimsPrincipal from persisted data
var claims = new List<Claim>
{
new Claim("sub", userInfo.UserId),
new Claim(ClaimTypes.Name, userInfo.Name),
new Claim(ClaimTypes.Email, userInfo.Email),
};
_authenticationStateTask = Task.FromResult(
new AuthenticationState(new ClaimsPrincipal(
new ClaimsIdentity(claims, authenticationType: nameof(PersistentAuthenticationStateProvider)))));
}
// Returns serialized state — does NOT make an HTTP request
public override Task<AuthenticationState> GetAuthenticationStateAsync()
=> _authenticationStateTask;
}
Client-side registration:
// BlazorAuthNZDemo.Client/Program.cs
var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.Services.AddAuthorizationCore();
builder.Services.AddCascadingAuthenticationState();
// Provider that reads the state persisted by the server
builder.Services.AddSingleton<AuthenticationStateProvider,
PersistentAuthenticationStateProvider>();
builder.Services.AddScoped(sp =>
new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
await builder.Build().RunAsync();
3.4 Shared UserInfo Model
// BlazorAuthNZDemo.Client/Models/UserInfo.cs
public class UserInfo
{
[Required]
public required string UserId { get; init; }
[Required]
public required string Name { get; init; }
[Required]
public required string Email { get; init; }
public string? Country { get; init; }
public IEnumerable<string>? Roles { get; init; }
}
4. Blazor Authorization Components
4.1 AuthorizeView — Conditional Display
@* Basic usage *@
<AuthorizeView>
<Authorized>
@* Visible only to authenticated users *@
<NavLink href="profile">My Profile</NavLink>
<button @onclick="Logout">Sign out</button>
</Authorized>
<NotAuthorized>
@* Visible only to NON-authenticated users *@
<NavLink href="/Account/Login">Sign in</NavLink>
<NavLink href="/Account/Register">Register</NavLink>
</NotAuthorized>
<Authorizing>
@* Visible during transition (e.g., Server → WASM) *@
<span>Checking...</span>
</Authorizing>
</AuthorizeView>
@* With user context access *@
<AuthorizeView>
<Authorized>
@* context is of type AuthenticationState *@
<p>Welcome, @context.User.Identity!.Name</p>
<p>Email: @context.User.FindFirst(System.Security.Claims.ClaimTypes.Email)?.Value</p>
<p>Your roles: @string.Join(", ", context.User.Claims
.Where(c => c.Type == System.Security.Claims.ClaimTypes.Role)
.Select(c => c.Value))</p>
</Authorized>
</AuthorizeView>
@* With role check *@
<AuthorizeView Roles="Admin,Manager">
<Authorized>
<NavLink href="/admin">Administration</NavLink>
</Authorized>
</AuthorizeView>
@* With authorization policy *@
<AuthorizeView Policy="IsFromBelgium">
<Authorized>
<NavLink href="/belgique">Belgium content</NavLink>
</Authorized>
<NotAuthorized>
<p>This content is reserved for Belgian users.</p>
</NotAuthorized>
</AuthorizeView>
4.2 CascadingAuthenticationState — How it Works
The CascadingAuthenticationState component is the mechanism that makes authentication state available to all descendant components without having to inject it manually everywhere.
// Program.cs — Automatic registration of CascadingAuthenticationState
builder.Services.AddCascadingAuthenticationState();
// Equivalent to wrapping the entire app in:
// <CascadingAuthenticationState>
// <Routes />
// </CascadingAuthenticationState>
Accessing auth state via CascadingParameter:
@code {
// Automatically receives auth state without explicit injection
[CascadingParameter]
private Task<AuthenticationState>? AuthenticationStateTask { get; set; }
private string? _userName;
private bool _isAuthenticated;
protected override async Task OnInitializedAsync()
{
if (AuthenticationStateTask is not null)
{
var authState = await AuthenticationStateTask;
_isAuthenticated = authState.User.Identity?.IsAuthenticated ?? false;
_userName = authState.User.Identity?.Name;
}
}
}
4.3 AuthorizeRouteView — Authorization During Routing
@* Components/Routes.razor — Routing configuration with authorization *@
<Router AppAssembly="typeof(Program).Assembly"
AdditionalAssemblies="new[] { typeof(Client._Imports).Assembly }">
<Found Context="routeData">
<AuthorizeRouteView RouteData="routeData"
DefaultLayout="typeof(Layout.MainLayout)">
<NotAuthorized>
@if (context.User.Identity?.IsAuthenticated ?? false)
{
@* Authenticated user but missing required permissions *@
<RedirectToAccessDenied />
}
else
{
@* Unauthenticated user *@
<RedirectToLogin />
}
</NotAuthorized>
<Authorizing>
<p>Checking permissions...</p>
</Authorizing>
</AuthorizeRouteView>
</Found>
<NotFound>
<PageTitle>Page not found</PageTitle>
<LayoutView Layout="typeof(Layout.MainLayout)">
<h1>404 — Page not found</h1>
</LayoutView>
</NotFound>
</Router>
@* Components/Account/Shared/RedirectToLogin.razor *@
@inject NavigationManager NavigationManager
@code {
protected override void OnInitialized()
{
// forceLoad: true because the login page is SSR (not interactive)
// It requires a full HTTP navigation (not a Blazor internal navigation)
NavigationManager.NavigateTo(
$"/Account/Login?returnUrl={Uri.EscapeDataString(NavigationManager.Uri)}",
forceLoad: true);
}
}
Important difference:
| Mechanism | When it applies | For what |
|-----------|--------------------|-----------||
| [Authorize] on page | Navigation to the page | Entire pages |
| AuthorizeRouteView | Any navigation | Validator for [Authorize] |
| <AuthorizeView> | Component rendering | UI portions |
| RequireAuthorization() | HTTP request to endpoint | APIs and endpoints |
5. Integration with Microsoft Entra ID (OpenID Connect)
5.1 Authorization Code Flow + PKCE Architecture
sequenceDiagram
participant User as User
participant Browser as Browser
participant Host as Blazor Host
participant EntraID as Microsoft Entra ID
Note over Host: code_verifier = generateRandom()
Note over Host: code_challenge = SHA256(code_verifier)
User->>Host: Click "Sign in"
Host->>Browser: 302 Redirect to Entra ID
Note over Browser: ?response_type=code&code_challenge=...
Browser->>EntraID: GET /authorize?code_challenge=...
EntraID->>Browser: Microsoft sign-in page
User->>EntraID: Enter credentials (email + password / MFA)
EntraID->>EntraID: Store code_challenge
EntraID->>Browser: 302 Redirect to Host
Note over Browser: ?code=AUTHORIZATION_CODE&state=...
Browser->>Host: GET /signin-oidc?code=...
Note over Host: State verification (anti-CSRF)
Host->>EntraID: POST /token
Note over Host: code + code_verifier + client_id + client_secret
EntraID->>EntraID: hash(code_verifier) == stored code_challenge?
EntraID->>Host: id_token + access_token + refresh_token
Host->>Host: Validate id_token (signature, expiration, audience)
Host->>Host: Create ClaimsPrincipal from id_token claims
Host->>Host: Create AspNetCore session cookie
Host->>Browser: 302 Redirect to requested page + Set-Cookie
Browser->>Browser: Store cookie (HttpOnly, Secure)
Note over Browser, Host: All subsequent requests\nsend the cookie automatically
5.2 OIDC Middleware Configuration
// Program.cs — Complete OpenID Connect configuration
const string EntraIDScheme = "EntraIDOpenIdConnect";
const string CookieScheme = "Cookies";
builder.Services.AddAuthentication(options =>
{
options.DefaultScheme = CookieScheme;
options.DefaultChallengeScheme = EntraIDScheme;
})
.AddCookie(CookieScheme, options =>
{
options.Cookie.Name = "BlazorApp.Auth";
options.Cookie.HttpOnly = true;
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
options.Cookie.SameSite = SameSiteMode.Lax;
options.ExpireTimeSpan = TimeSpan.FromHours(8);
options.SlidingExpiration = true;
options.AccessDeniedPath = "/access-denied";
options.LoginPath = "/login";
})
.AddOpenIdConnect(EntraIDScheme, options =>
{
options.ResponseType = OpenIdConnectResponseType.Code;
options.UsePkce = true;
options.SaveTokens = true;
options.Scope.Clear();
options.Scope.Add("openid");
options.Scope.Add("profile");
options.Scope.Add("email");
options.Scope.Add("offline_access");
options.Scope.Add($"api://{remoteApiClientId}/full_access");
options.MapInboundClaims = false;
options.TokenValidationParameters = new TokenValidationParameters
{
NameClaimType = "name",
RoleClaimType = "roles"
};
options.Events = new OpenIdConnectEvents
{
OnTokenValidated = async ctx =>
{
var tokenStore = ctx.HttpContext.RequestServices
.GetRequiredService<CustomServerSideTokenStore>();
var userId = ctx.Principal!.FindFirstValue("sub")!;
var accessToken = ctx.TokenEndpointResponse!.AccessToken;
var refreshToken = ctx.TokenEndpointResponse.RefreshToken;
var expiresIn = int.Parse(ctx.TokenEndpointResponse.ExpiresIn ?? "3600");
await tokenStore.StoreTokenAsync(userId, new StoredToken(
AccessToken: accessToken,
RefreshToken: refreshToken,
ExpiresAt: DateTimeOffset.UtcNow.AddSeconds(expiresIn)
));
}
};
});
builder.Services.AddCascadingAuthenticationState();
builder.Services.AddScoped<AuthenticationStateProvider,
ServerAuthenticationStateProvider>();
appsettings.json (non-secret values only):
{
"Authentication": {
"Schemes": {
"EntraIDOpenIdConnect": {
"Authority": "https://login.microsoftonline.com/{YOUR_TENANT_ID}/v2.0",
"ClientId": "{YOUR_CLIENT_ID}",
"CallbackPath": "/signin-oidc",
"SignedOutCallbackPath": "/signout-callback-oidc"
}
}
}
}
Security: In production, never store the
ClientSecretinappsettings.json. Use Azure Key Vault, environment variables, or the deployment environment’s Secrets Manager.
5.3 Login and Logout Endpoints
// Program.cs — Minimal endpoints for session management
// ─── LOGIN ─────────────────────────────────────────────────────
app.MapGet("/login", (string? returnUrl, HttpContext httpContext) =>
{
var safeUrl = ValidateReturnUrl(returnUrl, httpContext.Request.PathBase);
return TypedResults.Challenge(
new AuthenticationProperties { RedirectUri = safeUrl },
[EntraIDScheme]
);
}).AllowAnonymous();
// ─── LOGOUT ────────────────────────────────────────────────────
app.MapPost("/logout", (
[FromForm] string? returnUrl,
HttpContext httpContext) =>
{
var safeUrl = ValidateReturnUrl(returnUrl, httpContext.Request.PathBase);
// Sign out from BOTH schemes:
// 1. Local cookie → clears local session
// 2. OIDC → redirects to Entra ID to clear its session
return TypedResults.SignOut(
new AuthenticationProperties { RedirectUri = safeUrl },
[CookieScheme, EntraIDScheme]
);
}).RequireAntiForgeryToken();
// ─── HELPER — Return URL validation ─────────────────────────────
public partial class Program
{
private static string ValidateReturnUrl(string? returnUrl, PathString pathBase)
{
var basePath = pathBase.HasValue ? pathBase.Value : "/";
if (string.IsNullOrEmpty(returnUrl)) return basePath;
if (!Uri.IsWellFormedUriString(returnUrl, UriKind.RelativeOrAbsolute))
return basePath;
// Reject absolute URL (e.g., https://evil.com) — open redirect attack
if (Uri.TryCreate(returnUrl, UriKind.Absolute, out _))
return basePath;
if (!returnUrl.StartsWith('/'))
return basePath;
return returnUrl;
}
}
Sign-in button in NavMenu:
@* Components/Layout/NavMenu.razor *@
<nav>
<NavLink href="/">Home</NavLink>
<AuthorizeView>
<Authorized>
<NavLink href="/interactive-server">Server mode</NavLink>
<NavLink href="/interactive-wasm">WASM mode</NavLink>
<NavLink href="/interactive-auto">Auto mode</NavLink>
@* Logout — must be a POST (CSRF protection) *@
<form method="post" action="/logout" style="display: inline;">
<AntiForgeryToken />
<input type="hidden" name="returnUrl" value="/" />
<button type="submit" class="btn-link">
Sign out (@context.User.Identity!.Name)
</button>
</form>
</Authorized>
<NotAuthorized>
<a href="/login">Sign in</a>
</NotAuthorized>
</AuthorizeView>
</nav>
6. Protecting Local APIs with Cookies
6.1 Principle — Same Domain = Automatic Cookie
flowchart TB
subgraph Domain["host.example.com (same domain)"]
direction TB
BlazorHost[Blazor Host\n/]
LocalAPI["/localapi/bands\nLocal API"]
BlazorHost --- LocalAPI
end
Browser[Browser\nBlazer WASM] -->|"GET /localapi/bands\n+ Automatic cookie!"| LocalAPI
subgraph Rule["Cookie rules"]
R1["SameSite=Lax (default)\n→ Cookie sent for\nsame domain"]
R2["Domain not defined (default)\n→ Cookie sent for\nparent domain\nNOT subdomains"]
end
Why the cookie doesn’t go to remote APIs:
Session cookie from "host.example.com"
↓
Request to "host.example.com/localapi/bands" → ✅ Cookie sent (same domain)
Request to "api.example.com/remoteapi/bands" → ❌ Cookie NOT sent (different domain)
Request to "sub.host.example.com/api/bands" → ❌ Cookie NOT sent (subdomain, Domain not defined)
6.2 WASM Client — Explicitly Sending the Cookie
By default, HttpClient in a WASM application does not send cookies with requests. You must explicitly configure BrowserRequestCredentials.Include:
// ❌ WITHOUT credentials — Cookie not sent from WASM
var response = await _httpClient.GetAsync("/localapi/bands");
// Result: 401 Unauthorized
// ✅ WITH credentials — Cookie sent
var request = new HttpRequestMessage(HttpMethod.Get, "/localapi/bands");
request.SetBrowserRequestCredentials(BrowserRequestCredentials.Include);
var response = await _httpClient.SendAsync(request);
// Result: 200 OK (if cookie is valid)
6.3 Interactive Server — Direct Access Without HTTP
// InteractiveServerMode.razor — Server-side, no HTTP needed locally
@rendermode InteractiveServer
@inject BandsRepository BandsRepo
@code {
private List<Band>? _bands;
protected override async Task OnInitializedAsync()
{
// ✅ Direct call — no HTTP request to the host
// User is authenticated because the page is protected by cookie
// Code runs in the same process as the server
_bands = await BandsRepo.GetBandsAsync();
}
}
// BandsRepository.cs — Register in DI container
public class BandsRepository
{
public Task<List<Band>> GetBandsAsync()
{
return Task.FromResult(new List<Band>
{
new Band { Id = 1, Name = "Metallica", Genre = "Metal" },
new Band { Id = 2, Name = "Radiohead", Genre = "Alternative" },
new Band { Id = 3, Name = "Daft Punk", Genre = "Electronic" }
});
}
}
7. Protecting Remote APIs with Access Tokens
7.1 Why a Cookie Isn’t Enough for Remote APIs
Blazor Host: host.example.com
Remote API: api.example.com
The cookie is bound to host.example.com.
The browser will NEVER send it to api.example.com.
→ Need a different mechanism: OAuth2 tokens
7.2 BFF Pattern (Backend For Frontend)
flowchart LR
subgraph Dangerous["❌ DANGEROUS — Token in the browser"]
W1[WASM] -->|"1. Request the token"| H1[Host]
H1 -->|"2. Send the token\nto the browser"| W1
W1 -->|"3. Use the token\nfrom browser\n(localStorage or memory)"| API1[Remote API]
note1["Malicious JavaScript\ncan steal the token!"]
end
subgraph Safe["✅ SECURE — BFF Pattern"]
W2[WASM] -->|"1. GET /forward-to-remote-api/bands\n+ Cookie (local)"| BFF[BFF Proxy\non the Host]
BFF -->|"2. Fetch token\nfrom TokenStore"| TS["ServerSideTokenStore\n(server memory)"]
BFF -->|"3. GET /remoteapi/bands\nBearer TOKEN\n(token stays server-side)"| API2[Remote API]
note2["Token NEVER\nin the browser"]
end
style Dangerous fill:#FFE4E4
style Safe fill:#E4FFE4
7.3 CustomServerSideTokenStore
// Services/CustomServerSideTokenStore.cs
public record StoredToken(
string AccessToken,
string? RefreshToken,
DateTimeOffset ExpiresAt
);
public class CustomServerSideTokenStore
{
private readonly ConcurrentDictionary<string, StoredToken> _tokens = new();
public Task StoreTokenAsync(string userId, StoredToken token)
{
_tokens[userId] = token;
return Task.CompletedTask;
}
public Task<StoredToken?> GetTokenAsync(string userId)
{
_tokens.TryGetValue(userId, out var token);
return Task.FromResult(token);
}
public Task ClearTokenAsync(string userId)
{
_tokens.TryRemove(userId, out _);
return Task.CompletedTask;
}
public bool HasValidToken(string userId)
{
if (!_tokens.TryGetValue(userId, out var token))
return false;
// Consider token expired 60 seconds before actual expiration
return token.ExpiresAt > DateTimeOffset.UtcNow.AddSeconds(60);
}
}
7.4 DelegatingHandler to Attach Bearer Token
// Infrastructure/AccessTokenHandler.cs
public class AccessTokenHandler : DelegatingHandler
{
private readonly CustomServerSideTokenStore _tokenStore;
private readonly IHttpContextAccessor _contextAccessor;
private readonly IConfiguration _config;
private readonly ILogger<AccessTokenHandler> _logger;
public AccessTokenHandler(
CustomServerSideTokenStore tokenStore,
IHttpContextAccessor contextAccessor,
IConfiguration config,
ILogger<AccessTokenHandler> logger)
{
_tokenStore = tokenStore;
_contextAccessor = contextAccessor;
_config = config;
_logger = logger;
}
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
var userId = _contextAccessor.HttpContext?.User.FindFirstValue("sub");
if (userId is null)
{
_logger.LogWarning("Remote API request without authenticated user");
return new HttpResponseMessage(System.Net.HttpStatusCode.Unauthorized);
}
var token = await GetValidTokenAsync(userId, cancellationToken);
if (token is null)
return new HttpResponseMessage(System.Net.HttpStatusCode.Unauthorized);
request.Headers.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token.AccessToken);
return await base.SendAsync(request, cancellationToken);
}
private async Task<StoredToken?> GetValidTokenAsync(
string userId, CancellationToken cancellationToken)
{
var token = await _tokenStore.GetTokenAsync(userId);
if (token is null) return null;
if (_tokenStore.HasValidToken(userId))
return token;
if (token.RefreshToken is null)
return null;
return await RefreshAccessTokenAsync(userId, token.RefreshToken, cancellationToken);
}
private async Task<StoredToken?> RefreshAccessTokenAsync(
string userId,
string refreshToken,
CancellationToken cancellationToken)
{
var tenantId = _config["Authentication:Schemes:EntraIDOpenIdConnect:TenantId"];
var clientId = _config["Authentication:Schemes:EntraIDOpenIdConnect:ClientId"];
var clientSecret = _config["Authentication:Schemes:EntraIDOpenIdConnect:ClientSecret"];
var remoteApiClientId = _config["RemoteApi:ClientId"];
try
{
using var client = new HttpClient();
var response = await client.PostAsync(
$"https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/token",
new FormUrlEncodedContent(new Dictionary<string, string>
{
["grant_type"] = "refresh_token",
["client_id"] = clientId!,
["client_secret"] = clientSecret!,
["refresh_token"] = refreshToken,
["scope"] = $"api://{remoteApiClientId}/full_access offline_access"
}),
cancellationToken);
if (!response.IsSuccessStatusCode)
{
await _tokenStore.ClearTokenAsync(userId);
return null;
}
var tokenResponse = await response.Content
.ReadFromJsonAsync<TokenRefreshResponse>(cancellationToken: cancellationToken);
if (tokenResponse is null) return null;
var newToken = new StoredToken(
AccessToken: tokenResponse.AccessToken,
RefreshToken: tokenResponse.RefreshToken ?? refreshToken,
ExpiresAt: DateTimeOffset.UtcNow.AddSeconds(tokenResponse.ExpiresIn)
);
await _tokenStore.StoreTokenAsync(userId, newToken);
return newToken;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during token refresh for {UserId}", userId);
return null;
}
}
}
public record TokenRefreshResponse(
[property: JsonPropertyName("access_token")] string AccessToken,
[property: JsonPropertyName("refresh_token")] string? RefreshToken,
[property: JsonPropertyName("expires_in")] int ExpiresIn
);
Registration and BFF endpoint:
builder.Services.AddSingleton<CustomServerSideTokenStore>();
builder.Services.AddHttpContextAccessor();
builder.Services.AddTransient<AccessTokenHandler>();
builder.Services.AddHttpClient("RemoteApiClient", client =>
{
client.BaseAddress = new Uri("https://api.example.com");
})
.AddHttpMessageHandler<AccessTokenHandler>();
// BFF endpoint — proxy to remote API
app.MapGet("/forward-to-remote-api/bands", async (
IHttpClientFactory factory) =>
{
var client = factory.CreateClient("RemoteApiClient");
var bands = await client.GetFromJsonAsync<List<Band>>("/remoteapi/bands");
return Results.Ok(bands);
})
.RequireAuthorization();
7.5 Token Validation on the Remote API
// RemoteApi/Program.cs — JWT Bearer validation
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Authority = $"https://login.microsoftonline.com/{tenantId}/v2.0";
options.Audience = $"api://{remoteApiClientId}";
options.MapInboundClaims = false;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
NameClaimType = "name",
RoleClaimType = "roles",
ClockSkew = TimeSpan.FromMinutes(5)
};
});
app.UseAuthentication();
app.UseAuthorization();
app.MapGet("/remoteapi/bands", async (BandsService service) =>
Results.Ok(await service.GetBandsAsync()))
.RequireAuthorization();
8. Authorization Policies — RBAC vs ABAC
8.1 Comparison Between RBAC and ABAC
| Aspect | RBAC | ABAC |
|---|---|---|
| Based on | Assigned roles | User attributes (claims) |
| Example roles | Admin, Employee, Manager | — |
| Example claims | — | country, age, department, level |
| Granularity | Low | Very high |
| Maintainability | Can explode in number of roles | Composable policies |
| Rule complexity | Limited | Unlimited (with assertions) |
| Current preference | Historical | Recommended |
| ASP.NET Core | RequireRole() or [Authorize(Roles)] | RequireClaim(), RequireAssertion() |
8.2 Creating Authorization Policies
// BlazorAuthNZDemo.AuthorizationPolicies/Policies.cs
// (Shared class library project)
public static class Policies
{
public const string IsFromBelgium = "IsFromBelgium";
public const string IsAdmin = "IsAdmin";
public const string CanManageBands = "CanManageBands";
public const string IsSeniorEmployee = "IsSeniorEmployee";
// Policy 1: Authenticated user from Belgium (ABAC)
public static AuthorizationPolicy IsFromBelgiumPolicy =>
new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.RequireClaim("ctry", "BE")
.Build();
// Policy 2: Admin role (RBAC)
public static AuthorizationPolicy IsAdminPolicy =>
new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.RequireRole("Admin")
.Build();
// Policy 3: Admin OR Belgian manager (combined ABAC)
public static AuthorizationPolicy CanManageBandsPolicy =>
new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.RequireAssertion(context =>
{
var user = context.User;
if (user.IsInRole("Admin")) return true;
return user.IsInRole("Manager") && user.HasClaim("ctry", "BE");
})
.Build();
// Policy 4: Senior employee (combined claims)
public static AuthorizationPolicy IsSeniorEmployeePolicy =>
new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.RequireClaim("department", "Engineering", "IT", "Architecture")
.RequireAssertion(context =>
{
var levelClaim = context.User.FindFirst("jobLevel");
if (levelClaim is null) return false;
return levelClaim.Value is "Senior" or "Lead" or "Principal";
})
.Build();
}
Registering policies:
builder.Services.AddAuthorization(options =>
{
options.AddPolicy(Policies.IsFromBelgium, Policies.IsFromBelgiumPolicy);
options.AddPolicy(Policies.IsAdmin, Policies.IsAdminPolicy);
options.AddPolicy(Policies.CanManageBands, Policies.CanManageBandsPolicy);
options.AddPolicy(Policies.IsSeniorEmployee, Policies.IsSeniorEmployeePolicy);
options.DefaultPolicy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.Build();
});
8.3 Applying Policies
On Blazor pages:
@page "/belgium-only"
@attribute [Authorize(Policy = Policies.IsFromBelgium)]
<h1>Exclusive Belgium content</h1>
@page "/admin"
@attribute [Authorize(Policy = Policies.IsAdmin)]
<h1>Administration</h1>
In AuthorizeView:
<nav>
<AuthorizeView>
<Authorized>
<NavLink href="/dashboard">Dashboard</NavLink>
</Authorized>
</AuthorizeView>
<AuthorizeView Policy="@Policies.IsFromBelgium">
<Authorized>
<NavLink href="/belgium-only">Belgium content</NavLink>
</Authorized>
</AuthorizeView>
<AuthorizeView Policy="@Policies.IsAdmin">
<Authorized>
<NavLink href="/admin">Administration</NavLink>
</Authorized>
</AuthorizeView>
</nav>
On API endpoints:
// Local API with policy
app.MapGet("/localapi/bands", async (BandsRepository repo) =>
Results.Ok(await repo.GetBandsAsync()))
.RequireAuthorization(Policies.IsFromBelgium);
// BFF proxy with policy
app.MapGet("/forward-to-remote-api/bands", async (IHttpClientFactory factory) =>
{
var client = factory.CreateClient("RemoteApiClient");
var bands = await client.GetFromJsonAsync<List<Band>>("/remoteapi/bands");
return Results.Ok(bands);
})
.RequireAuthorization(Policies.IsFromBelgium);
// Remote API — same policy but checked with JWT
app.MapGet("/remoteapi/bands", async (BandsService service) =>
Results.Ok(await service.GetBandsAsync()))
.RequireAuthorization(Policies.IsFromBelgium);
Custom access denied page:
@* Components/Pages/AccessDenied.razor *@
@page "/access-denied"
<PageTitle>Access Denied</PageTitle>
<div class="access-denied-container">
<h1>🔒 Access Denied</h1>
<p>You do not have the required permissions to access this resource.</p>
<p>If you think this is an error, contact the administrator.</p>
<a href="/" class="btn btn-primary">Back to home</a>
</div>
9. Common Pitfalls and Anti-Patterns
9.1 The 7 Critical Mistakes to Avoid
flowchart TD
subgraph Pitfalls["🚨 Critical Pitfalls"]
P1["1. Relying on [Authorize] client-side in WASM\nto actually block access"]
P2["2. Injecting IHttpContextAccessor\nin interactive components"]
P3["3. Storing tokens in\nlocalStorage or sessionStorage"]
P4["4. Forgetting AddAuthorizationCore()\non the WASM client"]
P5["5. Not validating returnUrl\n→ Open redirect attack"]
P6["6. Logout via GET instead of POST\n→ CSRF risk"]
P7["7. Calling remote API\ndirectly from WASM"]
end
P1 -->|"Solution"| S1["Always enforce at API\nand server-side"]
P2 -->|"Solution"| S2["Use CascadingParameter\nTask~AuthenticationState~"]
P3 -->|"Solution"| S3["BFF Pattern — tokens\nstay on server"]
P4 -->|"Solution"| S4["Add all required services\nin client Program.cs"]
P5 -->|"Solution"| S5["ValidateReturnUrl()\nbefore any redirect"]
P6 -->|"Solution"| S6["Logout = POST\n+ AntiForgeryToken"]
P7 -->|"Solution"| S7["Use BFF endpoint\non the host"]
Code examples:
// ❌ Pitfall 1: False security client-side in WASM
@rendermode InteractiveWebAssembly
@attribute [Authorize(Policy = "SecretData")] // ← Bypassable by the user!
// ✅ Solution: Real protection is on the API
app.MapGet("/api/secret-data", async (...) => ...)
.RequireAuthorization("SecretData"); // ← The real barrier
// ❌ Pitfall 2: HttpContext unreliable in interactive mode
@inject IHttpContextAccessor HCA
@rendermode InteractiveServer
@code {
protected override void OnInitialized()
{
var user = HCA.HttpContext?.User; // MAY CRASH
}
}
// ✅ Solution: CascadingParameter or AuthStateProvider
[CascadingParameter]
private Task<AuthenticationState>? AuthStateTask { get; set; }
// ❌ Pitfall 3: Tokens in the browser
var response = await httpClient.GetAsync("/api/get-access-token");
var token = await response.Content.ReadAsStringAsync();
localStorage.setItem("token", token); // Accessible by any JS on the page → XSS!
// ✅ Solution: Token stays server-side, browser never accesses it
9.2 Handling Authorization Errors Client-Side
// ClientApiService.cs — Correct handling of WASM authorization errors
public class ClientApiService
{
private readonly HttpClient _httpClient;
private readonly NavigationManager _navigationManager;
public async Task<List<Band>?> GetLocalBandsAsync()
{
var request = new HttpRequestMessage(HttpMethod.Get, "/localapi/bands");
request.SetBrowserRequestCredentials(BrowserRequestCredentials.Include);
var response = await _httpClient.SendAsync(request);
if (response.StatusCode == HttpStatusCode.Unauthorized)
{
// Not authenticated → redirect to login
_navigationManager.NavigateTo("/login?returnUrl=/interactive-auto",
forceLoad: true);
return null;
}
if (response.StatusCode == HttpStatusCode.Forbidden)
{
// Authenticated but missing permissions → access denied page
_navigationManager.NavigateTo("/access-denied");
return null;
}
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<List<Band>>();
}
}
10. Practical Exercises
Exercise 1 — Complete Identity UI (Beginner, ~1h)
Objective: Create a Blazor application with full user management via Identity.
Steps:
- Create a Blazor Web App with Auto mode and Individual Accounts
- Apply
[Authorize]on 3 pages with different render modes - Display the user’s name in the NavMenu
- Implement a profile page that shows all claims
Starter code:
@* Pages/Profile.razor *@
@page "/profile"
@attribute [Authorize]
<h1>My Profile</h1>
<dl>
@* TODO: Display all user claims via AuthorizeView *@
@* Hint: context.User.Claims inside <AuthorizeView> *@
</dl>
Expected result:
- Redirect to login if unauthenticated
- Name display in NavMenu
- List of all claims on the profile page
Exercise 2 — OIDC with Entra ID (Intermediate, ~2h)
Prerequisites: Access to an Azure / Entra ID tenant
Objective: Replace local Identity with OIDC
Steps:
- Create an app registration in Entra ID
- Remove Identity configuration from Program.cs
- Configure
AddOpenIdConnectwith the right options - Implement
/loginand/logoutendpoints - Verify that logout properly clears both sessions (local + Entra ID)
Exercise 3 — Complete BFF Pattern (Advanced, ~3h)
Objective: Implement the full chain: WASM → BFF → Remote API
Architecture:
BlazorApp (host.localhost:5001)
↓ Cookie
BFF Proxy (/forward-to-remote/bands)
↓ Bearer token (from ServerSideTokenStore)
RemoteApi (api.localhost:5002)
Deliverables:
- Functional
CustomServerSideTokenStore AccessTokenHandlerwith automatic refresh- BFF endpoint protected by cookie
- Remote API validating JWT Bearer
Exercise 4 — Custom Policies (Advanced, ~1h30)
Scenario: Only employees from the “Engineering” department with 3+ years of experience and from an approved country (BE, NL, DE) can access certain sensitive data.
// To implement:
public class MultiAttributeRequirement : IAuthorizationRequirement
{
public IEnumerable<string> AllowedCountries { get; }
public string RequiredDepartment { get; }
public int MinYearsExperience { get; }
// TODO: Constructor
}
public class MultiAttributeHandler : AuthorizationHandler<MultiAttributeRequirement>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
MultiAttributeRequirement requirement)
{
// TODO: Check country, department, and yearsExperience
// Hint: context.User.FindFirst("ctry"), "department", "yearsExperience"
return Task.CompletedTask;
}
}
11. Technical Glossary
| Term | Definition |
|---|---|
| ABAC | Attribute-Based Access Control — access control by attributes/claims |
| Access Token | Short-lived JWT authorizing access to a specific API |
| Anti-Forgery Token | CSRF protection mechanism for forms |
| Audience (aud) | Intended recipient of a JWT token (identifies target API) |
| Authorization Code | Temporary OAuth2 code exchanged for tokens |
| AuthenticationState | Blazor object containing the ClaimsPrincipal of the current user |
| AuthenticationStateProvider | Abstract Blazor service providing auth state to components |
| AuthorizeRouteView | Blazor component validating [Authorize] during navigation |
| AuthorizeView | Blazor component for conditional display based on auth state |
| BFF Pattern | Backend For Frontend — server proxy for secure WASM calls |
| Bearer Token | HTTP token sent in the Authorization: Bearer xxx header |
| CascadingParameter | Blazor mechanism to pass values to all descendant components |
| Challenge | Action triggering the authentication flow (e.g., redirect to Entra ID) |
| Claim | Statement about a user (name, email, country, role, etc.) |
| ClaimsPrincipal | .NET representation of a user with their identities and claims |
| Cookie | File sent to the browser containing the authentication ticket (HttpOnly) |
| CSRF | Cross-Site Request Forgery — attack using site trust in the user |
| DelegatingHandler | HttpClient middleware intercepting and modifying outgoing requests |
| Entra ID | Microsoft’s identity service (formerly Azure Active Directory / AAD) |
| HttpOnly | Cookie attribute preventing JavaScript access (XSS protection) |
| IDP | Identity Provider — centralized identity provider |
| Identity Token | OpenID Connect JWT proving user identity |
| Interactive Auto | Blazor render mode: Server at first render, then WASM after cache |
| Interactive Server | Blazor render mode using SignalR for interface updates |
| JWT | JSON Web Token — standard format for tokens (header.payload.signature) |
| OAuth2 | Authorization protocol for delegating access to resources |
| OIDC / OpenID Connect | Extension of OAuth2 for authentication and identity management |
| PKCE | Proof Key for Code Exchange — OAuth2 protection against code injection |
| RBAC | Role-Based Access Control — access control by predefined roles |
| Refresh Token | Long-lived token exchanged for a new access token |
| SameSite | Cookie attribute controlling cross-site requests (Strict/Lax/None) |
| Scope | Permission requested during OIDC sign-in (openid, profile, api://xxx) |
| Security Stamp | ASP.NET Identity hash changing on security-related account modifications |
| SignalR | Microsoft technology for real-time communication via WebSocket |
| SSO | Single Sign-On — single authentication for multiple applications |
| SSR | Server-Side Rendering — HTML rendered server-side |
| WASM | WebAssembly — binary format running in the browser |
| XSS | Cross-Site Scripting — injection of malicious scripts into a web page |
Additional Resources
- Official documentation:
- Tools:
- Security standards:
Search Terms
asp.net · authentication · authorization · core · blazor · security · c# · .net · development · identity · policies · access · apis · cookie · protecting · remote · abac · architecture · bff · client · configuration · entra · management · mode