Intermediate

Authentication and Authorization in ASP.NET Core Blazor

Blazor render modes and security, Identity UI, AuthenticationStateProvider, Entra ID and RBAC vs ABAC.

Technology: Blazor Web App (auto render mode), ASP.NET Core, Entra ID Level: Intermediate | Estimated duration: ~3h30


Table of Contents

  1. Blazor Render Modes and Security Impact
  2. User Management — Blazor Identity UI
  3. AuthenticationStateProvider In Depth
  4. Blazor Authorization Components
  5. Integration with Microsoft Entra ID (OpenID Connect)
  6. Protecting Local APIs with Cookies
  7. Protecting Remote APIs with Access Tokens
  8. Authorization Policies — RBAC vs ABAC
  9. Common Pitfalls and Anti-Patterns
  10. Practical Exercises
  11. 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:

PhaseActive modeHttpContextAuto cookieSecurity strategy
1st visitInteractive Server (SignalR)⚠️ Available but unreliable✅ Yes (initial)Server AuthStateProvider
Subsequent visitsInteractive WebAssembly❌ Not available⚠️ To configureClient AuthStateProvider + BFF
PrerenderingAlways on server✅ Available✅ YesHttpContext.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:

  1. Provides authentication state to server components by reading HttpContext.User
  2. Persists user information in the HTML page (as JSON) so the WASM client can read it
  3. 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 ClientSecret in appsettings.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

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)

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

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

AspectRBACABAC
Based onAssigned rolesUser attributes (claims)
Example rolesAdmin, Employee, Manager
Example claimscountry, age, department, level
GranularityLowVery high
MaintainabilityCan explode in number of rolesComposable policies
Rule complexityLimitedUnlimited (with assertions)
Current preferenceHistoricalRecommended
ASP.NET CoreRequireRole() 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:

  1. Create a Blazor Web App with Auto mode and Individual Accounts
  2. Apply [Authorize] on 3 pages with different render modes
  3. Display the user’s name in the NavMenu
  4. 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:

  1. Create an app registration in Entra ID
  2. Remove Identity configuration from Program.cs
  3. Configure AddOpenIdConnect with the right options
  4. Implement /login and /logout endpoints
  5. 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
  • AccessTokenHandler with 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

TermDefinition
ABACAttribute-Based Access Control — access control by attributes/claims
Access TokenShort-lived JWT authorizing access to a specific API
Anti-Forgery TokenCSRF protection mechanism for forms
Audience (aud)Intended recipient of a JWT token (identifies target API)
Authorization CodeTemporary OAuth2 code exchanged for tokens
AuthenticationStateBlazor object containing the ClaimsPrincipal of the current user
AuthenticationStateProviderAbstract Blazor service providing auth state to components
AuthorizeRouteViewBlazor component validating [Authorize] during navigation
AuthorizeViewBlazor component for conditional display based on auth state
BFF PatternBackend For Frontend — server proxy for secure WASM calls
Bearer TokenHTTP token sent in the Authorization: Bearer xxx header
CascadingParameterBlazor mechanism to pass values to all descendant components
ChallengeAction triggering the authentication flow (e.g., redirect to Entra ID)
ClaimStatement about a user (name, email, country, role, etc.)
ClaimsPrincipal.NET representation of a user with their identities and claims
CookieFile sent to the browser containing the authentication ticket (HttpOnly)
CSRFCross-Site Request Forgery — attack using site trust in the user
DelegatingHandlerHttpClient middleware intercepting and modifying outgoing requests
Entra IDMicrosoft’s identity service (formerly Azure Active Directory / AAD)
HttpOnlyCookie attribute preventing JavaScript access (XSS protection)
IDPIdentity Provider — centralized identity provider
Identity TokenOpenID Connect JWT proving user identity
Interactive AutoBlazor render mode: Server at first render, then WASM after cache
Interactive ServerBlazor render mode using SignalR for interface updates
JWTJSON Web Token — standard format for tokens (header.payload.signature)
OAuth2Authorization protocol for delegating access to resources
OIDC / OpenID ConnectExtension of OAuth2 for authentication and identity management
PKCEProof Key for Code Exchange — OAuth2 protection against code injection
RBACRole-Based Access Control — access control by predefined roles
Refresh TokenLong-lived token exchanged for a new access token
SameSiteCookie attribute controlling cross-site requests (Strict/Lax/None)
ScopePermission requested during OIDC sign-in (openid, profile, api://xxx)
Security StampASP.NET Identity hash changing on security-related account modifications
SignalRMicrosoft technology for real-time communication via WebSocket
SSOSingle Sign-On — single authentication for multiple applications
SSRServer-Side Rendering — HTML rendered server-side
WASMWebAssembly — binary format running in the browser
XSSCross-Site Scripting — injection of malicious scripts into a web page

Additional Resources


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

Interested in this course?

Contact us to book it or get a custom training plan for your team.