Intermediate

Authentication and Authorization in ASP.NET Core 10 Blazor

Secure a Blazor Web App with Identity, render-mode-aware auth state, Entra ID and policies.

Demo Application: BlazorAuthNZDemo (Blazor Web App with auto render mode) Technology: ASP.NET Core / .NET 10, Blazor Server + WebAssembly, Microsoft Entra ID Level: Intermediate to Advanced | Estimated duration: ~3h


Table of Contents

  1. Architecture and Blazor Render Modes
  2. Authentication with ASP.NET Core Identity
  3. Authentication State Propagation
  4. Integration with OpenID Connect (Entra ID)
  5. API Protection
  6. Authorization Policies
  7. Common Pitfalls and Best Practices
  8. Practical Exercises
  9. Glossary

1. Architecture and Blazor Render Modes

1.1 Overview of the .NET 10 SSR-First Model

In ASP.NET Core 10, the Blazor Web App template represents the reference model for building web applications. The fundamental change compared to previous versions is the adoption of an SSR-first paradigm (Server-Side Rendering first). By default, all components are rendered server-side and interactivity is deliberately added where needed.

This paradigm shift has profound implications for the security of the application. In .NET 10, SSR is the default mode, and authentication naturally relies on HttpContext.User, which is the standard ASP.NET Core approach.

Key concept: In the SSR-first model, security boundaries shift depending on where a component executes. Understanding these boundaries is essential for implementing correct security.

1.2 The 4 Render Modes

flowchart TD
    A[Blazor Web App .NET 10] --> B{Chosen render mode}
    B --> C[Static Server - SSR]
    B --> D[Interactive Server]
    B --> E[Interactive WebAssembly]
    B --> F[Interactive Auto]
    
    C --> C1[Static HTML server-side]
    C --> C2[No SignalR]
    C --> C3[HttpContext.User available]
    C --> C4[Standard HTML forms]
    
    D --> D1[Permanent SignalR connection]
    D --> D2[State on the server]
    D --> D3[HttpContext limited after init]
    D --> D4[Full interactivity]
    
    E --> E1[C# code in the browser]
    E --> E2[Via WebAssembly runtime]
    E --> E3[HttpContext NOT available]
    E --> E4[Works offline]
    
    F --> F1[Starts in Server mode]
    F --> F2[Switches to WASM after cache]
    F --> F3[Best user experience]
    F --> F4[Hybrid security management]
    
    style C fill:#90EE90
    style D fill:#87CEEB
    style E fill:#FFD700
    style F fill:#FF9999
ModeExecutionHttpContextCookie sentSecurity strategy
Static SSRServer only✅ Available✅ AutomaticFull auth via cookies
Interactive ServerServer (SignalR)⚠️ Not reliable in interactive⚠️ Only at initial renderUse AuthStateProvider
Interactive WASMBrowser❌ Not available❌ Must configure manuallyBFF pattern for APIs
Interactive AutoServer → WASM⚠️ Hybrid⚠️ HybridCombination of both approaches

Static Server (Pure SSR)

This is the simplest and most secure mode from an authentication standpoint. Each interaction generates a complete HTTP request/response cycle. The authentication cookie is automatically sent by the browser with each request, and HttpContext.User is always available and reliable.

// In a pure SSR component - HttpContext always available
@page "/profile"
@inject IHttpContextAccessor HttpContextAccessor

@code {
    private string? _userName;
    
    protected override void OnInitialized()
    {
        // This works perfectly in SSR
        _userName = HttpContextAccessor.HttpContext?.User.Identity?.Name;
    }
}

Interactive Server

This mode maintains a persistent SignalR connection between the browser and the server. The UI is streamed via this WebSocket connection. The important thing to understand is that HttpContext is available during initial rendering (prerendering), but is not reliable during the interactive session, since there are no real HTTP requests — only SignalR messages.

// ❌ BAD - HttpContext not reliable in interactive mode
@rendermode InteractiveServer
@inject IHttpContextAccessor HttpContextAccessor

@code {
    protected override async Task OnInitializedAsync()
    {
        // HttpContext may be null or stale here!
        var user = HttpContextAccessor.HttpContext?.User; // DANGEROUS
    }
}

// ✅ GOOD - Use AuthenticationStateProvider
@rendermode InteractiveServer
@inject AuthenticationStateProvider AuthStateProvider

@code {
    protected override async Task OnInitializedAsync()
    {
        var authState = await AuthStateProvider.GetAuthenticationStateAsync();
        var user = authState.User; // Reliable and up to date
    }
}

This is the recommended mode for most applications. It starts in Server mode for a quick initial response, then switches to WASM once the WebAssembly bundle is downloaded and cached.

// Component in Auto mode - must handle both cases
@rendermode InteractiveAuto
@inject AuthenticationStateProvider AuthStateProvider

@code {
    // Works in both modes thanks to the AuthStateProvider injection
    // On server: IdentityRevalidatingAuthenticationStateProvider
    // On client: PersistentAuthenticationStateProvider (deserialized)
    protected override async Task OnInitializedAsync()
    {
        var authState = await AuthStateProvider.GetAuthenticationStateAsync();
        var user = authState.User;
    }
}

1.3 Project Structure: BlazorAuthNZDemo

Solution BlazorAuthNZDemo
├── BlazorAuthNZDemo/                    (Server project — Blazor host)
│   ├── Components/
│   │   ├── Account/                     (Scaffolded Identity UI)
│   │   │   ├── Pages/
│   │   │   │   ├── Login.razor
│   │   │   │   ├── Register.razor
│   │   │   │   ├── Logout.razor
│   │   │   │   ├── ForgotPassword.razor
│   │   │   │   ├── ResetPassword.razor
│   │   │   │   └── Manage/
│   │   │   └── Shared/
│   │   │       └── RedirectToLogin.razor
│   │   ├── Pages/
│   │   │   ├── Home.razor
│   │   │   ├── InteractiveServerMode.razor
│   │   │   └── InteractiveAutoMode.razor
│   │   ├── Layout/
│   │   │   ├── NavMenu.razor
│   │   │   └── MainLayout.razor
│   │   ├── Routes.razor
│   │   └── App.razor
│   ├── Data/
│   │   ├── ApplicationDbContext.cs
│   │   └── BlazorAuthNZDemoUser.cs
│   ├── Services/
│   │   └── BandsRepository.cs
│   ├── TokenStores/
│   │   └── ServerSideTokenStore.cs
│   └── Program.cs
│
├── BlazorAuthNZDemo.Client/             (WASM client project)
│   ├── Components/
│   │   └── Pages/
│   │       └── InteractiveAutoMode.razor
│   ├── Services/
│   │   └── WasmBandsClient.cs
│   └── Program.cs
│
└── RemoteApi/                           (Remote API — separate domain)
    ├── Controllers/
    │   └── BandsController.cs
    ├── Authorization/
    │   └── Policies.cs
    └── Program.cs

2. Authentication with ASP.NET Core Identity

2.1 Comparison of Authentication Approaches

CriterionASP.NET Core Identity (local)OpenID Connect / Entra ID
User storageLocal databaseCentralized at identity provider
Password managementApp’s responsibilityDelegated to provider
MFAMust implement yourselfBuilt into provider
SSONo (unless special config)✅ Yes, native
Multi-app integrationComplexSimple via standard protocols
ProtocolsASP.NET Core proprietaryOAuth2 + OpenID Connect
Implementation complexityLow to mediumMedium
Recommended forStandalone appsEnterprise apps, SaaS

Course recommendation: For most production scenarios, the OpenID Connect approach with a centralized identity provider (Entra ID, Auth0, Okta) is preferable. It centralizes user management and leverages proven security protocols.

2.2 Blazor Identity UI and ASP.NET Core Identity

Blazor Identity UI is a set of pre-built Razor components for common IAM tasks:

  • User registration
  • Sign in / Sign out
  • Password reset
  • Email confirmation
  • Multi-factor authentication (MFA)
  • External provider integration (Google, Microsoft, etc.)
classDiagram
    class BlazorIdentityUI {
        +Login.razor
        +Register.razor
        +Logout.razor
        +ForgotPassword.razor
        +ResetPassword.razor
        +Manage/Index.razor
    }
    
    class ASPNETCoreIdentity {
        +UserManager~TUser~
        +SignInManager~TUser~
        +RoleManager~TRole~
        +IPasswordHasher~TUser~
        +IUserValidator~TUser~
    }
    
    class EntityFramework {
        +ApplicationDbContext
        +IdentityUser
        +IdentityRole
        +Migrations
    }
    
    BlazorIdentityUI --> ASPNETCoreIdentity : uses
    ASPNETCoreIdentity --> EntityFramework : persists via

Critical point: Even if the Razor components can be rendered client-side or server-side, the C# code that calls ASP.NET Core Identity classes (SignInManager, UserManager) always executes on the server. This is intentional — you don’t want authentication logic executing in the browser.

2.3 Scaffolding Blazor Identity

Option 1 — New project: In Visual Studio, when creating the Blazor Web App project, select Individual Accounts as the authentication type.

Option 2 — Existing project:

Visual Studio → Right-click on server project
→ Add → New Scaffolded Item
→ Identity → Blazor Identity
→ Configure:
    - DbContext class: ApplicationDbContext (new)
    - User class: BlazorAuthNZDemoUser (inherits IdentityUser)
    - Database provider: SQLite or SQL Server
→ Click Add

2.4 Program.cs Configuration (Identity)

// Program.cs — Complete Identity configuration for Blazor .NET 10

var builder = WebApplication.CreateBuilder(args);
var connectionString = builder.Configuration
    .GetConnectionString("DefaultConnection") 
    ?? throw new InvalidOperationException("Connection string not found.");

// ═══════════════════════════════════════════════════════════════
// 1. Razor Components with all render modes
// ═══════════════════════════════════════════════════════════════
builder.Services.AddRazorComponents()
    .AddInteractiveServerComponents()
    .AddInteractiveWebAssemblyComponents();

// ═══════════════════════════════════════════════════════════════
// 2. Database Context for Identity
// ═══════════════════════════════════════════════════════════════
builder.Services.AddDbContext<ApplicationDbContext>(options =>
    options.UseSqlite(connectionString));
builder.Services.AddDatabaseDeveloperPageExceptionFilter();

// ═══════════════════════════════════════════════════════════════
// 3. ASP.NET Core Identity
// ═══════════════════════════════════════════════════════════════
builder.Services.AddIdentityCore<BlazorAuthNZDemoUser>(options =>
{
    options.Password.RequiredLength = 8;
    options.Password.RequireNonAlphanumeric = true;
    options.Password.RequireUppercase = true;
    
    options.SignIn.RequireConfirmedAccount = true;
    
    options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(5);
    options.Lockout.MaxFailedAccessAttempts = 5;
})
.AddRoles<IdentityRole>()
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddSignInManager()
.AddDefaultTokenProviders();

// ═══════════════════════════════════════════════════════════════
// 4. Cookie authentication scheme
// ═══════════════════════════════════════════════════════════════
builder.Services.AddAuthentication(options =>
{
    options.DefaultScheme = IdentityConstants.ApplicationScheme;
    options.DefaultSignInScheme = IdentityConstants.ExternalScheme;
})
.AddIdentityCookies();

// ═══════════════════════════════════════════════════════════════
// 5. NEW .NET 10 — Cascading + serialized authentication state
// ═══════════════════════════════════════════════════════════════
builder.Services.AddCascadingAuthenticationState();
// Serializes auth state from server so it can be read by WASM client
builder.Services.AddAuthenticationStateSerialization();

// ═══════════════════════════════════════════════════════════════
// 6. Business services
// ═══════════════════════════════════════════════════════════════
builder.Services.AddScoped<BandsRepository>();

var app = builder.Build();

// ═══════════════════════════════════════════════════════════════
// Middleware pipeline (ORDER MATTERS!)
// ═══════════════════════════════════════════════════════════════
if (app.Environment.IsDevelopment())
{
    app.UseWebAssemblyDebugging();
    app.UseMigrationsEndPoint();
}
else
{
    app.UseExceptionHandler("/Error");
    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);

// Automatically generated Identity endpoints (login, logout, register, etc.)
app.MapAdditionalIdentityEndpoints();

app.Run();

WASM Client — Program.cs:

// BlazorAuthNZDemo.Client/Program.cs
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;

var builder = WebAssemblyHostBuilder.CreateDefault(args);

// Client-side authorization services
builder.Services.AddAuthorizationCore();
builder.Services.AddCascadingAuthenticationState();

// NEW .NET 10 — Read auth state serialized by the server
builder.Services.AddAuthenticationStateDeserialization();

// HTTP client for local API calls
builder.Services.AddScoped(sp =>
    new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });

await builder.Build().RunAsync();

2.5 IdentityRevalidatingAuthenticationStateProvider

This crucial component is generated by the scaffolding. It ensures authentication state remains valid during an interactive Blazor Server session:

// Components/Account/IdentityRevalidatingAuthenticationStateProvider.cs
// (Generated by scaffolding — do not modify without reason)

internal sealed class IdentityRevalidatingAuthenticationStateProvider
    : RevalidatingServerAuthenticationStateProvider
{
    private readonly IServiceScopeFactory _scopeFactory;
    
    public IdentityRevalidatingAuthenticationStateProvider(
        ILoggerFactory loggerFactory,
        IServiceScopeFactory scopeFactory)
        : base(loggerFactory)
    {
        _scopeFactory = scopeFactory;
    }
    
    // Revalidates the security stamp every 30 minutes
    // If the stamp no longer matches (e.g., password changed), disconnects the user
    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;
    }
}

Why the security stamp? When a user changes their password, their roles change, or an administrator revokes a session, the security stamp changes. The revalidator detects this change and forces logout, preventing a compromised session from remaining active too long.


3. Authentication State Propagation

3.1 AuthenticationStateProvider — Central Concept

AuthenticationStateProvider is a fundamental abstract class in Blazor. It provides the current authentication state to components.

// Abstract definition (in Microsoft.AspNetCore.Components.Authorization)
public abstract class AuthenticationStateProvider
{
    public abstract Task<AuthenticationState> GetAuthenticationStateAsync();
    public event AuthenticationStateChangedHandler? AuthenticationStateChanged;
    protected void NotifyAuthenticationStateChanged(Task<AuthenticationState> task);
}

public class AuthenticationState
{
    public AuthenticationState(ClaimsPrincipal user) { User = user; }
    public ClaimsPrincipal User { get; }
}

Implementations by context:

ContextImplementationHow it works
Server (Identity)IdentityRevalidatingAuthenticationStateProviderReads HttpContext.User + revalidates stamp
Server (OIDC)Built-in ServerAuthenticationStateProviderReads HttpContext.User from OIDC cookie
WASM Client (.NET 10)PersistentAuthenticationStateProviderDeserializes state sent by server

3.2 .NET 10 Serialization Mechanism

flowchart LR
    subgraph Server["Server"]
        SC[HttpContext.User\nClaimsPrincipal]
        SS[AddAuthenticationStateSerialization\n.NET 10]
        SC --> SS
        SS --> |"Serializes claims\ninto HTML page"| HTML
    end
    
    subgraph Client["WASM Client"]
        DS[AddAuthenticationStateDeserialization\n.NET 10]
        CP[ClaimsPrincipal reconstructed\nclient-side]
        DS --> CP
        CP --> |"Available via\nAuthStateProvider"| UI
    end
    
    HTML --> |"WASM bundle loaded,\nclaims read from DOM"| DS
    
    style Server fill:#e6f3ff
    style Client fill:#fff3e6

Server-side — Claims serialization:

// In server Program.cs
builder.Services.AddAuthenticationStateSerialization();
// This service automatically injects serialized claims into the rendered HTML
// as a hidden <template> element with JSON data

Client-side — Deserialization:

// In WASM client Program.cs
builder.Services.AddAuthenticationStateDeserialization();
// This service reads the serialized data from the HTML and reconstructs the ClaimsPrincipal
// Result: AuthenticationStateProvider.GetAuthenticationStateAsync()
// returns the correct user client-side, without additional HTTP calls

3.3 Using Authentication State in Components

Approach 1 — CascadingParameter:

@code {
    [CascadingParameter]
    private Task<AuthenticationState>? authStateTask { get; set; }
    
    private AuthenticationState? authState;
    
    protected override async Task OnInitializedAsync()
    {
        if (authStateTask != null)
            authState = await authStateTask;
    }
}

Approach 2 — AuthorizeView (most common in template):

<AuthorizeView>
    <Authorized>
        <p>Welcome, @context.User.Identity!.Name!</p>
    </Authorized>
    <NotAuthorized>
        <p>Please <a href="/Account/Login">sign in</a>.</p>
    </NotAuthorized>
    <Authorizing>
        <p>Checking...</p>
    </Authorizing>
</AuthorizeView>

@* With authorization policy *@
<AuthorizeView Policy="@Policies.IsFromBelgium">
    <Authorized>
        <p>Content for Belgian users only.</p>
    </Authorized>
</AuthorizeView>

@* With role *@
<AuthorizeView Roles="Admin,Manager">
    <Authorized>
        <p>Content for admins and managers.</p>
    </Authorized>
</AuthorizeView>

Approach 3 — [Authorize] attribute on pages:

@page "/protected"
@attribute [Authorize]
@* or with policy: @attribute [Authorize(Policy = "IsFromBelgium")] *@
@* or with role: @attribute [Authorize(Roles = "Admin")] *@

<h1>Protected page</h1>

3.4 Routes.razor and AuthorizeRouteView

@* Components/Routes.razor *@
<Router AppAssembly="typeof(Program).Assembly" 
        AdditionalAssemblies="new[] { typeof(Client._Imports).Assembly }">
    <Found Context="routeData">
        <AuthorizeRouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)">
            <NotAuthorized>
                <RedirectToLogin />
            </NotAuthorized>
            <Authorizing>
                <p>Loading...</p>
            </Authorizing>
        </AuthorizeRouteView>
    </Found>
    <NotFound>
        <PageTitle>Not found</PageTitle>
        <LayoutView Layout="typeof(Layout.MainLayout)">
            <p role="alert">Sorry, this page does not exist.</p>
        </LayoutView>
    </NotFound>
</Router>
@* Components/Account/Shared/RedirectToLogin.razor *@
@inject NavigationManager NavigationManager

@code {
    protected override void OnInitialized()
    {
        // forceLoad: true = full navigation (required for SSR pages)
        var returnUrl = Uri.EscapeDataString(NavigationManager.Uri);
        NavigationManager.NavigateTo($"/Account/Login?returnUrl={returnUrl}", 
            forceLoad: true);
    }
}

4. Integration with OpenID Connect (Entra ID)

4.1 OAuth2 and OpenID Connect — Conceptual Overview

sequenceDiagram
    participant User as User
    participant App as Blazor Host
    participant EntraID as Microsoft Entra ID
    participant RemoteAPI as Remote API

    User->>App: Access to protected page
    App->>EntraID: Redirect to Authorization Endpoint\n(PKCE code_challenge)
    EntraID->>User: Entra ID sign-in page
    User->>EntraID: Credentials (email + password / MFA)
    EntraID->>App: Authorization Code + state

    App->>EntraID: POST Token Endpoint\n(code + code_verifier + client_secret)
    EntraID->>App: identity_token + access_token + refresh_token
    
    App->>App: Validate identity_token
    App->>App: Create ClaimsPrincipal from identity_token
    App->>App: Create local session cookie
    App->>User: Redirect to protected page (with cookie)

    Note over App,RemoteAPI: To access remote API:
    App->>RemoteAPI: HTTP Request + Bearer access_token
    RemoteAPI->>RemoteAPI: Validate access_token (signature, audience, expiration)
    RemoteAPI->>App: JSON data

Key concepts:

TermDefinition
OAuth2Authorization protocol — “what can I do?”
OpenID ConnectIdentity layer on OAuth2 — “who are you?”
Authorization CodeTemporary code exchanged for tokens
PKCEProtection against code injection (Proof Key for Code Exchange)
identity_tokenJWT proving user identity
access_tokenJWT authorizing access to a specific resource
refresh_tokenLong-lived token to obtain new access_tokens
Audience (aud)Who the token is intended for (the API that should accept it)
ScopeRequested permissions (openid, profile, email, custom_scope)

4.2 Registering the Application in Entra ID

Registering the Blazor host application:

Azure Portal → Microsoft Entra ID → App registrations → New registration
→ Name: "Blazor OIDC Demo"
→ Supported account types: "Accounts in this organizational directory only"
→ Redirect URI: 
    - Type: Web
    - URI: https://localhost:PORT/signin-oidc
→ Register

After creation:
→ Certificates & secrets → New client secret
    - Description: "BlazorApp Secret"
    - Expires: 24 months
    - Copy the value immediately (won't be visible again)
    
→ Token configuration → Add optional claim
    - Token type: ID
    - Claim: ctry (country)

4.3 AddOpenIdConnect Configuration in Program.cs

// Program.cs — Complete OIDC configuration (without local Identity)

const string OidcScheme = "EntraIDOpenIdConnect";

builder.Services.AddRazorComponents()
    .AddInteractiveServerComponents()
    .AddInteractiveWebAssemblyComponents();

builder.Services.AddAuthentication(options =>
{
    options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme = OidcScheme;
})
.AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, options =>
{
    options.Cookie.Name = "BlazorApp.Session";
    options.Cookie.HttpOnly = true;
    options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
    options.Cookie.SameSite = SameSiteMode.Lax;
    options.ExpireTimeSpan = TimeSpan.FromHours(8);
    options.SlidingExpiration = true;
})
.AddOpenIdConnect(OidcScheme, options =>
{
    options.Authority = $"https://login.microsoftonline.com/{tenantId}/v2.0";
    options.ClientId = builder.Configuration["Entra:ClientId"];
    options.ClientSecret = builder.Configuration["Entra:ClientSecret"];
    options.ResponseType = OpenIdConnectResponseType.Code;
    
    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.SaveTokens = true;
    options.MapInboundClaims = false;
    options.TokenValidationParameters = new TokenValidationParameters
    {
        NameClaimType = "name",
        RoleClaimType = "roles"
    };
    
    options.Events = new OpenIdConnectEvents
    {
        OnTokenValidated = async context =>
        {
            var tokenStore = context.HttpContext.RequestServices
                .GetRequiredService<ServerSideTokenStore>();
            
            var userId = context.Principal?.FindFirstValue("sub")
                ?? throw new InvalidOperationException("Missing sub claim");
            
            var accessToken = context.TokenEndpointResponse?.AccessToken
                ?? throw new InvalidOperationException("Missing access token");
            var refreshToken = context.TokenEndpointResponse?.RefreshToken;
            var expiresIn = int.Parse(context.TokenEndpointResponse?.ExpiresIn ?? "3600");
            
            await tokenStore.StoreTokensAsync(userId, new TokenInfo(
                AccessToken: accessToken,
                RefreshToken: refreshToken,
                ExpiresAt: DateTimeOffset.UtcNow.AddSeconds(expiresIn)
            ));
        }
    };
});

builder.Services.AddCascadingAuthenticationState();
builder.Services.AddAuthenticationStateSerialization();
builder.Services.AddSingleton<ServerSideTokenStore>();

4.4 Login and Logout Endpoints

// Program.cs — Minimal endpoints for login/logout OIDC

// ─── LOGIN ──────────────────────────────────────────────────────
app.MapGet("/login", (string? returnUrl, HttpContext httpContext) =>
{
    var validatedReturnUrl = ValidateReturnUrl(returnUrl, httpContext.Request.PathBase);
    
    return TypedResults.Challenge(
        new AuthenticationProperties { RedirectUri = validatedReturnUrl },
        [OidcScheme]
    );
}).AllowAnonymous();

// ─── LOGOUT ─────────────────────────────────────────────────────
app.MapPost("/logout", async (
    [FromForm] string? returnUrl,
    HttpContext httpContext) =>
{
    var validatedReturnUrl = ValidateReturnUrl(returnUrl, httpContext.Request.PathBase);
    
    // Sign out from BOTH schemes:
    // 1. Local cookie (invalidates session in our app)
    // 2. OIDC (redirects to Entra ID to invalidate its session too)
    return TypedResults.SignOut(
        new AuthenticationProperties { RedirectUri = validatedReturnUrl },
        [CookieAuthenticationDefaults.AuthenticationScheme, OidcScheme]
    );
}).RequireAntiForgeryToken();

// ─── HELPER METHOD ─────────────────────────────────────────────
static string ValidateReturnUrl(string? returnUrl, PathString pathBase)
{
    if (string.IsNullOrEmpty(returnUrl))
        return pathBase.Value ?? "/";
    
    if (!Uri.IsWellFormedUriString(returnUrl, UriKind.RelativeOrAbsolute))
        return pathBase.Value ?? "/";
    
    // Reject absolute URLs (open redirect protection)
    if (Uri.TryCreate(returnUrl, UriKind.Absolute, out _))
        return pathBase.Value ?? "/";
    
    if (!returnUrl.StartsWith('/'))
        return pathBase.Value ?? "/";
    
    return returnUrl;
}

Logout button in NavMenu:

@* In NavMenu.razor *@
<AuthorizeView>
    <Authorized>
        <form method="post" action="/logout">
            <AntiForgeryToken />
            <input type="hidden" name="returnUrl" value="/" />
            <button type="submit" class="nav-link btn btn-link">
                Sign out (@context.User.Identity!.Name)
            </button>
        </form>
    </Authorized>
    <NotAuthorized>
        <a class="nav-link" href="/login">Sign in</a>
    </NotAuthorized>
</AuthorizeView>

Why POST for logout? Logout must be a POST action (not GET) for two reasons: 1) Protect against CSRF attacks that could sign out the user via a malicious link. 2) Allow adding an anti-forgery token to validate that the user initiated the sign-out.


5. API Protection

5.1 Local API vs Remote API — Architecture

flowchart TB
    subgraph Browser["Browser"]
        WASM[Blazor WASM\nClient]
    end
    
    subgraph Host["host.example.com — Blazor Host"]
        BlazorServer[Blazor Server\nSSR + SignalR]
        LocalAPI["/localapi/bands\nLocal API"]
        BFF["/forward-to-remote-api/bands\nBFF Proxy"]
        TokenStore[ServerSideTokenStore\nAccess tokens]
        
        BFF --> TokenStore
    end
    
    subgraph Remote["api.example.com — Remote API"]
        RemoteAPI["/remoteapi/bands\nRemote API"]
    end
    
    WASM -->|"Cookie sent\nautomatically"| LocalAPI
    WASM -->|"Cookie sent\nautomatically"| BFF
    BFF -->|"Bearer access_token\n(from TokenStore)"| RemoteAPI
    BlazorServer -->|"Direct (no HTTP)"| LocalAPI
    BlazorServer -->|"Bearer access_token"| RemoteAPI
    
    style Browser fill:#FFF3CD
    style Host fill:#D4EDDA
    style Remote fill:#D1ECF1

The principle is simple: since the cookie is automatically sent by the browser for all requests to the same domain, the local API benefits from this protection naturally.

Server side — Secure the endpoint:

app.UseAuthentication();
app.UseAuthorization();

app.MapGet("/localapi/bands", async (BandsRepository repo) =>
{
    var bands = await repo.GetBandsAsync();
    return Results.Ok(bands);
})
.RequireAuthorization();

WASM client side — Send the cookie:

// ⚠️ WASM does NOT automatically send cookies by default
// You must explicitly configure credentials

public class WasmBandsClient
{
    private readonly HttpClient _httpClient;
    
    public async Task<List<Band>?> GetLocalBandsAsync()
    {
        var request = new HttpRequestMessage(HttpMethod.Get, "/localapi/bands");
        
        // Without this line, the cookie is NOT sent from WASM
        request.SetBrowserRequestCredentials(BrowserRequestCredentials.Include);
        
        var response = await _httpClient.SendAsync(request);
        response.EnsureSuccessStatusCode();
        
        return await response.Content.ReadFromJsonAsync<List<Band>>();
    }
}

Interactive server side — Direct access without HTTP:

// InteractiveServerMode.razor — On server, no HTTP needed
@inject BandsRepository BandsRepo

@code {
    private List<Band>? _bands;
    
    protected override async Task OnInitializedAsync()
    {
        // ✅ Direct repository access — no local HTTP request
        _bands = await BandsRepo.GetBandsAsync();
    }
}

5.3 Remote API — Access Tokens and BFF Pattern

Why the BFF Pattern?

flowchart LR
    subgraph Problem["❌ Without BFF (DANGEROUS)"]
        W1[WASM] -->|"Access Token\nin the browser!"| R1[Remote API]
        note1["Token exposed to all JS\non the page - XSS risk!"]
    end
    
    subgraph Solution["✅ With BFF (SECURE)"]
        W2[WASM] -->|"Cookie only\n(HttpOnly)"| BFF2[BFF Proxy\non the Host]
        BFF2 -->|"Bearer Token\n(server-side)"| R2[Remote API]
        note2["Token never in\nthe browser!"]
    end
    
    style Problem fill:#FFE4E4
    style Solution fill:#E4FFE4

Server-side token storage:

// TokenStores/ServerSideTokenStore.cs

public record TokenInfo(
    string AccessToken,
    string? RefreshToken,
    DateTimeOffset ExpiresAt
);

public class ServerSideTokenStore
{
    private readonly ConcurrentDictionary<string, TokenInfo> _tokens = new();
    
    public Task StoreTokensAsync(string userId, TokenInfo tokenInfo)
    {
        _tokens[userId] = tokenInfo;
        return Task.CompletedTask;
    }
    
    public Task<TokenInfo?> GetTokensAsync(string userId)
    {
        _tokens.TryGetValue(userId, out var tokenInfo);
        return Task.FromResult(tokenInfo);
    }
    
    public Task ClearTokensAsync(string userId)
    {
        _tokens.TryRemove(userId, out _);
        return Task.CompletedTask;
    }
    
    public Task<bool> HasValidTokenAsync(string userId)
    {
        if (!_tokens.TryGetValue(userId, out var tokenInfo))
            return Task.FromResult(false);
        
        return Task.FromResult(tokenInfo.ExpiresAt > DateTimeOffset.UtcNow.AddSeconds(30));
    }
}

DelegatingHandler to attach the token:

// Infrastructure/AccessTokenDelegatingHandler.cs

public class AccessTokenDelegatingHandler : DelegatingHandler
{
    private readonly ServerSideTokenStore _tokenStore;
    private readonly IHttpContextAccessor _httpContextAccessor;
    private readonly ILogger<AccessTokenDelegatingHandler> _logger;
    
    public AccessTokenDelegatingHandler(
        ServerSideTokenStore tokenStore,
        IHttpContextAccessor httpContextAccessor,
        ILogger<AccessTokenDelegatingHandler> logger)
    {
        _tokenStore = tokenStore;
        _httpContextAccessor = httpContextAccessor;
        _logger = logger;
    }
    
    protected override async Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request, 
        CancellationToken cancellationToken)
    {
        var userId = _httpContextAccessor.HttpContext?.User
            .FindFirstValue(ClaimTypes.NameIdentifier);
        
        if (userId == null)
            return new HttpResponseMessage(System.Net.HttpStatusCode.Unauthorized);
        
        var tokenInfo = await _tokenStore.GetTokensAsync(userId);
        
        if (tokenInfo == null)
            return new HttpResponseMessage(System.Net.HttpStatusCode.Unauthorized);
        
        if (tokenInfo.ExpiresAt <= DateTimeOffset.UtcNow.AddSeconds(30))
        {
            tokenInfo = await RefreshTokenAsync(userId, tokenInfo);
            if (tokenInfo == null)
                return new HttpResponseMessage(System.Net.HttpStatusCode.Unauthorized);
        }
        
        request.Headers.Authorization = 
            new System.Net.Http.Headers.AuthenticationHeaderValue(
                "Bearer", tokenInfo.AccessToken);
        
        return await base.SendAsync(request, cancellationToken);
    }
    
    private async Task<TokenInfo?> RefreshTokenAsync(string userId, TokenInfo currentToken)
    {
        if (string.IsNullOrEmpty(currentToken.RefreshToken))
            return null;
        
        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"] = currentToken.RefreshToken,
                    ["scope"] = $"api://{remoteApiClientId}/full_access offline_access"
                }));
            
            if (!response.IsSuccessStatusCode)
            {
                await _tokenStore.ClearTokensAsync(userId);
                return null;
            }
            
            var tokenResponse = await response.Content
                .ReadFromJsonAsync<TokenRefreshResponse>();
            
            var newTokenInfo = new TokenInfo(
                AccessToken: tokenResponse!.AccessToken,
                RefreshToken: tokenResponse.RefreshToken ?? currentToken.RefreshToken,
                ExpiresAt: DateTimeOffset.UtcNow.AddSeconds(tokenResponse.ExpiresIn)
            );
            
            await _tokenStore.StoreTokensAsync(userId, newTokenInfo);
            return newTokenInfo;
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error during token refresh for {UserId}", userId);
            return null;
        }
    }
}

Handler registration and BFF endpoint:

builder.Services.AddScoped<AccessTokenDelegatingHandler>();
builder.Services.AddHttpContextAccessor();

builder.Services.AddHttpClient("RemoteApiClient", client =>
{
    client.BaseAddress = new Uri("https://api.example.com");
})
.AddHttpMessageHandler<AccessTokenDelegatingHandler>();

// ─── BFF ENDPOINT ────────────────────────────────────────────────
app.MapGet("/forward-to-remote-api/bands", async (
    IHttpClientFactory httpClientFactory) =>
{
    var client = httpClientFactory.CreateClient("RemoteApiClient");
    var bands = await client.GetFromJsonAsync<List<Band>>("/remoteapi/bands");
    return Results.Ok(bands);
})
.RequireAuthorization();

5.4 Refresh Tokens — Managing Expiration

sequenceDiagram
    participant WASM as Blazor WASM
    participant BFF as Host (BFF)
    participant TokenStore as ServerSideTokenStore
    participant EntraID as Entra ID Token Endpoint
    participant API as Remote API

    WASM->>BFF: GET /forward-to-remote-api/bands (+ cookie)
    BFF->>BFF: Validate cookie → OK
    BFF->>TokenStore: GetTokensAsync(userId)
    
    alt Valid token
        TokenStore-->>BFF: TokenInfo (valid access_token)
        BFF->>API: GET /remoteapi/bands (Bearer access_token)
        API-->>BFF: 200 OK + data
        BFF-->>WASM: 200 OK + data
    else Expired token but refresh_token available
        TokenStore-->>BFF: TokenInfo (expired access_token)
        BFF->>EntraID: POST /token\n(grant_type=refresh_token)
        EntraID-->>BFF: New access_token + refresh_token
        BFF->>TokenStore: StoreTokensAsync(new TokenInfo)
        BFF->>API: GET /remoteapi/bands (Bearer new access_token)
        API-->>BFF: 200 OK + data
        BFF-->>WASM: 200 OK + data
    else Expired token AND no refresh_token
        BFF-->>WASM: 401 Unauthorized
        WASM->>WASM: Redirect to /login
    end

6. Authorization Policies

6.1 RBAC vs ABAC — In-depth Comparison

flowchart LR
    subgraph RBAC["RBAC — Role-Based Access Control"]
        U1[User] -->|"has role"| R1[Admin]
        U1 -->|"has role"| R2[Manager]
        R1 -->|"can"| A1[Create]
        R1 -->|"can"| A2[Delete]
        R2 -->|"can"| A3[Read]
        R2 -->|"can"| A4[Edit]
    end
    
    subgraph ABAC["ABAC — Attribute-Based Access Control"]
        U2[User] -->|"has attributes"| C1["country = BE"]
        U2 -->|"has attributes"| C2["dept = IT"]
        U2 -->|"has attributes"| C3["level = Senior"]
        POL[Policy] -->|"evaluates"| C1
        POL -->|"evaluates"| C2
        POL -->|"evaluates"| C3
        POL -->|"decision"| DEC["Access granted/denied"]
    end
    
    style RBAC fill:#FFE4B5
    style ABAC fill:#B5FFB5

Comparison table:

CriterionRBACABAC
GranularityLow (broad roles)Fine (claim combinations)
ComplexitySimpleModerate
ScalabilityRole explosionComposable policies
MaintenanceRole assignmentPolicy definition
ASP.NET CoreRequireRole()RequireClaim(), Requirements

6.2 Defining Policies

// BlazorAuthNZDemo.Client/Authorization/Policies.cs

public static class Policies
{
    public const string IsFromBelgium = "IsFromBelgium";
    public const string RequiresAdminRole = "RequiresAdminRole";
    public const string IsSeniorEmployee = "IsSeniorEmployee";
    
    // Policy: authenticated user from Belgium
    public static AuthorizationPolicy IsFromBelgiumPolicy()
    {
        return new AuthorizationPolicyBuilder()
            .RequireAuthenticatedUser()
            .RequireClaim("ctry", "BE")
            .Build();
    }
    
    // Policy: Admin role
    public static AuthorizationPolicy RequiresAdminRolePolicy()
    {
        return new AuthorizationPolicyBuilder()
            .RequireAuthenticatedUser()
            .RequireRole("Admin")
            .Build();
    }
    
    // Combined policy: Belgian admin OR senior employee
    public static AuthorizationPolicy IsSeniorEmployeePolicy()
    {
        return new AuthorizationPolicyBuilder()
            .RequireAuthenticatedUser()
            .RequireAssertion(context =>
            {
                var user = context.User;
                var isAdminFromBelgium = user.IsInRole("Admin") 
                    && user.HasClaim("ctry", "BE");
                var isSenior = user.HasClaim("jobLevel", "Senior");
                
                return isAdminFromBelgium || isSenior;
            })
            .Build();
    }
}

Server-side policy registration:

builder.Services.AddAuthorization(options =>
{
    options.AddPolicy(Policies.IsFromBelgium, Policies.IsFromBelgiumPolicy());
    options.AddPolicy(Policies.RequiresAdminRole, Policies.RequiresAdminRolePolicy());
    options.AddPolicy(Policies.IsSeniorEmployee, Policies.IsSeniorEmployeePolicy());
    
    options.DefaultPolicy = new AuthorizationPolicyBuilder()
        .RequireAuthenticatedUser()
        .Build();
    
    options.FallbackPolicy = new AuthorizationPolicyBuilder()
        .RequireAuthenticatedUser()
        .Build();
});

6.3 Applying Policies

On Blazor pages:

@page "/belgium-only"
@attribute [Authorize(Policy = Policies.IsFromBelgium)]
@using BlazorAuthNZDemo.Client.Authorization

<h1>Content for Belgium only</h1>

On AuthorizeView:

<AuthorizeView Policy="@Policies.IsFromBelgium">
    <Authorized>
        <NavLink href="belgium-only">Belgium content</NavLink>
    </Authorized>
</AuthorizeView>

On API endpoints:

app.MapGet("/localapi/bands", async (BandsRepository repo) =>
    Results.Ok(await repo.GetBandsAsync()))
    .RequireAuthorization(Policies.IsFromBelgium);

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);

7. Common Pitfalls and Best Practices

7.1 The 6 Critical Mistakes to Avoid

flowchart TD
    subgraph Pitfalls["Critical Pitfalls"]
        P1["❌ 1. Trusting [Authorize] client-side in WASM"]
        P2["❌ 2. Injecting HttpContext in interactive mode"]
        P3["❌ 3. Exposing tokens to the browser"]
        P4["❌ 4. Forgetting auth services on the client"]
        P5["❌ 5. Ignoring prerendering"]
        P6["❌ 6. Not validating open redirect"]
    end
    
    subgraph Solutions["Solutions"]
        S1["✅ Always enforce at API/server"]
        S2["✅ Use AuthStateProvider or CascadingParam"]
        S3["✅ BFF Pattern — tokens stay on server"]
        S4["✅ AddCascadingAuthenticationState + AddAuthorizationCore"]
        S5["✅ Handle state during OnInitializedAsync"]
        S6["✅ ValidateReturnUrl() on login/logout"]
    end
    
    P1 --> S1
    P2 --> S2
    P3 --> S3
    P4 --> S4
    P5 --> S5
    P6 --> S6

Pitfall 1 — Trusting client-side authorization

// ❌ DANGEROUS — WASM code can be bypassed!
@page "/admin"
@attribute [Authorize(Roles = "Admin")]
@rendermode InteractiveWebAssembly
// Someone can modify the token client-side or use DevTools to circumvent this!

// ✅ CORRECT — Always check on the API
app.MapGet("/api/admin-data", async (...) => ...)
    .RequireAuthorization(Policies.RequiresAdminRole); // ← The real security

Pitfall 2 — HttpContext in interactive mode

// ❌ BAD — HttpContext not reliable during SignalR
@inject IHttpContextAccessor HttpContextAccessor
@rendermode InteractiveServer

@code {
    protected override void OnInitialized()
    {
        // MAY BE NULL or stale!
        var user = HttpContextAccessor.HttpContext?.User;
    }
}

// ✅ CORRECT — AuthStateProvider works in all modes
@inject AuthenticationStateProvider AuthStateProvider

@code {
    protected override async Task OnInitializedAsync()
    {
        var authState = await AuthStateProvider.GetAuthenticationStateAsync();
        var user = authState.User; // Always reliable
    }
}

Pitfall 3 — Tokens in the browser

// ❌ VERY DANGEROUS — Never do this!
localStorage.setItem('access_token', response.access_token);
// Any JS script on the page can read localStorage
// XSS attack → your token is compromised

// ✅ CORRECT — Tokens stay on the server (BFF Pattern)
// The browser only has access to an HttpOnly cookie

Pitfall 4 — Missing auth services on WASM client

// ❌ MISSING — Authorization silently broken client-side
var builder = WebAssemblyHostBuilder.CreateDefault(args);
// Missing AddCascadingAuthenticationState()!
// Missing AddAuthorizationCore()!

// ✅ CORRECT — All required services
var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.Services.AddAuthorizationCore();
builder.Services.AddCascadingAuthenticationState();
builder.Services.AddAuthenticationStateDeserialization(); // .NET 10

Pitfall 5 — Ignoring prerendering

// ❌ PROBLEM — Prerendering on server even for WASM components
@rendermode InteractiveWebAssembly

@code {
    protected override async Task OnInitializedAsync()
    {
        // During prerendering, we're on the server
        // Browser APIs don't exist yet!
        await JSRuntime.InvokeAsync<string>("localStorage.getItem", "key"); // ❌ Crash!
    }
}

// ✅ CORRECT — Detect prerendering
@code {
    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender)
        {
            // Now we're in the browser — localStorage available
            var value = await JSRuntime.InvokeAsync<string>("localStorage.getItem", "key");
            StateHasChanged();
        }
    }
}

Pitfall 6 — Open redirect attack

// ❌ VULNERABLE — Unvalidated return URL
app.MapGet("/login", (string? returnUrl) =>
{
    // If returnUrl = "https://evil.com/phishing"
    // The user will be redirected to a malicious site after login!
    return TypedResults.Challenge(
        new AuthenticationProperties { RedirectUri = returnUrl }, // DANGEROUS
        [OidcScheme]);
});

// ✅ CORRECT — Validate the return URL
app.MapGet("/login", (string? returnUrl, HttpContext httpContext) =>
{
    var validatedUrl = ValidateReturnUrl(returnUrl, httpContext.Request.PathBase);
    return TypedResults.Challenge(
        new AuthenticationProperties { RedirectUri = validatedUrl },
        [OidcScheme]);
});

7.2 Blazor Security Checklist

CategoryCheckStatus
AuthenticationHttpOnly + Secure + SameSite=Lax cookie
AuthenticationUseAuthentication() before UseAuthorization()
AuthenticationAnti-forgery configured (UseAntiforgery())
AuthorizationAPI endpoints protected with RequireAuthorization()
AuthorizationPolicies registered on server AND remote API
TokensAccess tokens stored server-side only
TokensBFF Pattern for WASM → remote API calls
TokensRefresh token implemented to avoid re-login
WASMAddCascadingAuthenticationState() + AddAuthorizationCore()
WASMSetBrowserRequestCredentials(Include) for cookies
OIDCValidateReturnUrl() on login and logout
OIDCLogout from both schemes (cookie + OIDC)
GeneralMapInboundClaims = false to avoid claim transformation

7.3 Secure appsettings.json Configuration

// appsettings.json — Don't put secrets here in production!
{
  "ConnectionStrings": {
    "DefaultConnection": "Data Source=app.db"
  },
  "Entra": {
    "TenantId": "your-tenant-id-here",
    "ClientId": "your-client-id-here"
    // ClientSecret: use user secrets in development
    //               use Azure Key Vault or env vars in production
  },
  "RemoteApi": {
    "BaseUrl": "https://api.example.com",
    "ClientId": "remote-api-client-id"
  }
}

// In development — Secret Manager (never commit!)
// dotnet user-secrets set "Entra:ClientSecret" "your-secret-here"

// In production — Azure App Service environment variables
// ENTRA__CLIENTSECRET=your-secret-here
// (Double underscore for nested sections)

8. Practical Exercises

Exercise 1 — Identity Scaffolding (Beginner)

Objective: Create a Blazor Web App with integrated Identity.

Steps:

  1. Create a new Blazor Web App with .NET 10
  2. Render mode: Interactive Auto
  3. Authentication: Individual Accounts
  4. Launch the application and explore the generated pages
  5. Create a user account
  6. Implement a “My Profile” button visible only when signed in

Starter code:

@* Pages/Profile.razor *@
@page "/profile"
@attribute [Authorize]
@inject AuthenticationStateProvider AuthStateProvider

<h1>My Profile</h1>

@* TODO: Display the authenticated user's claims *@

@code {
    private AuthenticationState? _authState;
    
    protected override async Task OnInitializedAsync()
    {
        _authState = await AuthStateProvider.GetAuthenticationStateAsync();
    }
}

Exercise 2 — OpenID Connect (Intermediate)

Objective: Replace Identity with OIDC using Entra ID.

Prerequisites:

  • Active Azure account
  • Access to an Entra ID tenant

Steps:

  1. Remove local Identity components
  2. Register the application in Entra ID
  3. Configure AddOpenIdConnect in Program.cs
  4. Implement the /login and /logout endpoints
  5. Test the complete sign-in/sign-out flow

Validation points:

  • Redirect to Microsoft when clicking “Sign in”
  • Cookie created after successful authentication
  • Cookie deleted after sign-out
  • Entra ID session also terminated

Exercise 3 — BFF Pattern (Advanced)

Objective: Implement the BFF Pattern to secure calls from WASM to a remote API.

Target architecture:

WASM → Host (BFF) → Remote API

Steps:

  1. Create a separate RemoteApi project
  2. Protect endpoints with RequireAuthorization()
  3. Create ServerSideTokenStore
  4. Create AccessTokenDelegatingHandler
  5. Implement the BFF proxy endpoint
  6. Test with a valid and an expired token

Exercise 4 — Custom Policies (Advanced)

Scenario: Only users 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; }
}

public class MultiAttributeHandler : AuthorizationHandler<MultiAttributeRequirement>
{
    protected override Task HandleRequirementAsync(
        AuthorizationHandlerContext context,
        MultiAttributeRequirement requirement)
    {
        // TODO: Check country, department, and yearsExperience
        return Task.CompletedTask;
    }
}

9. Glossary

TermDefinition
ABACAttribute-Based Access Control — access control based on attributes (claims)
Access TokenJWT authorizing access to a specific resource (API)
Anti-Forgery TokenToken to prevent CSRF attacks on forms
Audience (aud)JWT claim identifying the intended recipient of the token
AuthenticationStateObject containing the ClaimsPrincipal of the current user
AuthenticationStateProviderBlazor service providing authentication state to components
AuthorizeRouteViewComponent that validates [Authorize] during routing
AuthorizeViewComponent for conditional display based on auth state
BFF PatternBackend For Frontend — server proxy for secure WASM calls
Bearer TokenHTTP authorization mechanism where token is sent in Authorization header
ClaimStatement about a user (name, email, role, country, etc.)
ClaimsPrincipal.NET representation of a user with their claims
CookieFile stored in the browser containing the authentication ticket
CSRFCross-Site Request Forgery — attack exploiting site trust in user
DelegatingHandlerHttpClient middleware to intercept and modify requests
Entra IDMicrosoft’s identity service (formerly Azure Active Directory)
HttpContextASP.NET Core HTTP context containing request, response, user, etc.
Identity TokenJWT proving user identity (OpenID Connect)
IDPIdentity Provider — centralized identity provider
Interactive AutoBlazor render mode combining Server (initial) and WASM (after cache)
Interactive ServerBlazor render mode using SignalR for UI updates
JWTJSON Web Token — standard format for auth tokens
OAuth2Open authorization protocol (access delegation)
OIDCOpenID Connect — identity layer on OAuth2
PKCEProof Key for Code Exchange — OAuth2 protection against code injection
RBACRole-Based Access Control — access control based on roles
Redirect URIURL the IDP redirects to after authentication
Refresh TokenLong-lived token to obtain a new access token without re-login
ScopePermission requested from IDP (openid, profile, api://xxx/full_access)
Security StampASP.NET Identity hash that changes on critical account modifications
SignalRASP.NET Core technology for real-time communication via WebSocket
SSRServer-Side Rendering — HTML rendered server-side
WASMWebAssembly — bytecode 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 · pitfall · identity · api · policies · configuration · connect · interactive · openid · tokens · architecture · bff · comparison · entra · local · modes

Interested in this course?

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