Secure an ASP.NET Core Blazor Web app using Microsoft Entra ID

Blazor Web is the render mode model that .NET 8 introduced to unify Blazor Server and Blazor WebAssembly inside one project. You can mix interactive server components, interactive WebAssembly components and static server rendered pages in the same app. That flexibility is genuinely useful, but it also changes how authentication has to be wired up compared to the older hosted Blazor WASM or plain Blazor Server models.

Damien Bod’s walkthrough on securing a Blazor Web app with Microsoft Entra ID goes into exactly what changes. This piece covers how to set up an OpenID Connect confidential client using Microsoft.Identity.Web, how the login and logout endpoints should be implemented, and where the security headers story gets weaker once WebAssembly interactivity is involved. Damien built this off an example from Tomas Lopez Rodriguez, and the working sample is available on GitHub if you want to clone it and follow along.

Setting up the OpenID Connect confidential client

The Blazor Web app acts as an OpenID Connect confidential client using the authorization code flow with PKCE. An Azure App registration configured as a Web application creates this client, and only delegated scopes are used, meaning the app only ever acts on behalf of a signed in user, never on its own. A client secret works fine for local development, but production deployments should move to client assertions with a certificate instead.

The AddMicrosoftIdentityWebAppAuthentication method from Microsoft.Identity.Web wires this up. Notice that a downstream API is registered even though the app might not call one immediately. This is a deliberate trick: registering a downstream API forces the client to use the authorization code flow with PKCE instead of quietly falling back to implicit flow, which you do not want in a modern application.

// Add authentication services
var scopes = builder.Configuration.GetValue<string>("DownstreamApi:Scopes");
string[] initialScopes = scopes!.Split(' ');
 
builder.Services.AddMicrosoftIdentityWebAppAuthentication(builder.Configuration)
    .EnableTokenAcquisitionToCallDownstreamApi(initialScopes)
    .AddMicrosoftGraph("https://graph.microsoft.com/v1.0", scopes)
    .AddInMemoryTokenCaches();

Once this runs, the app redirects unauthenticated users to Microsoft Entra ID, handles the code exchange, and caches the resulting tokens so Microsoft Graph calls do not force a fresh sign in on every request. The scope used here, User.ReadBasic.All plus user.read, only pulls basic profile data, so remember to grant admin consent for that permission in the App registration or the token acquisition step will fail with a consent error the first time a user signs in.

AddInMemoryTokenCaches is fine for a single instance running locally, but it will not survive a restart and will not work correctly once you scale the app to multiple instances behind a load balancer, since each instance would cache tokens independently. For production, swap this for AddDistributedTokenCaches backed by Redis or a similar distributed cache so token refresh keeps working regardless of which instance handles a given request.

Configuration values that drive the client

The AzureAd section in appsettings.json feeds directly into AddMicrosoftIdentityWebAppAuthentication, so the client reads its tenant, client ID, and secret from here without any extra code. You can rename this section if you want, but the standard name keeps things predictable for anyone else who opens the project later.

"AzureAd": {
  "Instance": "https://login.microsoftonline.com/",
  "Domain": "[Enter the domain of your tenant, e.g. contoso.onmicrosoft.com]",
  "TenantId": "[Enter 'common', or 'organizations' or the Tenant Id]",
  "ClientId": "[Enter the Client Id (Application ID obtained from the Azure portal)]",
  "ClientSecret": "[Copy the client secret added to the app from the Azure portal]",
  "ClientCertificates": [],
  // required to handle Continuous Access Evaluation challenges
  "ClientCapabilities": [ "cp1" ],
  "CallbackPath": "/signin-oidc"
},
"DownstreamApi": {
  "Scopes": "User.ReadBasic.All user.read"
}

Two things here are easy to miss. First, CallbackPath has to match the redirect URI you registered on the App registration exactly, including the leading slash. A mismatch here throws a generic AADSTS50011 error that gives no hint about the actual cause, so check this first if sign in fails right after the redirect back from Entra ID. Second, the cp1 client capability tells Entra ID this app can handle Continuous Access Evaluation challenges, which matters if your tenant has Conditional Access policies that can revoke a token mid session, for example when a user’s location or risk score changes.

Login and logout endpoints

Damien implements login and logout as two minimal API endpoints in a small AuthenticationExtensions class rather than relying on MVC controllers. The login endpoint is a plain HTTP GET that challenges the OpenID Connect handler and redirects to Entra ID. The logout endpoint is deliberately a POST that requires authorization, which matters more than it looks.

using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.AspNetCore.Authentication;
namespace BlazorWebMeID;
 
public static class AuthenticationExtensions
{
    public static WebApplication SetupEndpoints(this WebApplication app)
    {
        app.MapGet("/Account/Login", async (HttpContext httpContext, string returnUrl = "/") =>
        {
            await httpContext.ChallengeAsync(OpenIdConnectDefaults.AuthenticationScheme,
                new AuthenticationProperties
                {
                    RedirectUri = !string.IsNullOrEmpty(returnUrl) ? returnUrl : "/"
                });
        });
 
        app.MapPost("/Account/Logout", async (HttpContext httpContext) =>
        {
            var authenticationProperties = new AuthenticationProperties
            {
                RedirectUri = "/SignedOut"
            };
 
            await httpContext.SignOutAsync(OpenIdConnectDefaults.AuthenticationScheme,
                authenticationProperties);
 
            await httpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
 
        }).RequireAuthorization();
 
        return app;
    }
}

Using POST with RequireAuthorization on the logout endpoint blocks a common attack where a malicious page embeds a hidden GET request to force a visitor out of their session. Because Blazor’s built in antiforgery protection covers POST requests, an attacker cannot trigger this endpoint from another site. The redirect URI after sign out is also hardcoded to /SignedOut rather than accepted from a query parameter, which closes off open redirect attacks where an attacker crafts a link that logs a user out and sends them to a phishing page.

A mistake worth calling out here: some tutorials still show logout as a GET endpoint for simplicity. Do not copy that pattern into a real application. GET requests can be triggered by an img tag or a background fetch without any user interaction, so authentication state changes should never live behind a GET.

Security headers and where Blazor Web falls short

The sample uses NetEscapades.AspNetCore.SecurityHeaders, a well known library among ASP.NET Core developers, to build a HeaderPolicyCollection covering frame options, referrer policy, cross origin policies, and a content security policy. Most of this transfers cleanly to Blazor Web. The part that does not is the script-src directive, and this is the single biggest caveat in the whole article.

namespace HostedBlazorMeID.Server;
 
public static class SecurityHeadersDefinitions
{
    public static HeaderPolicyCollection GetHeaderPolicyCollection(bool isDev, string? idpHost)
    {
        ArgumentNullException.ThrowIfNull(idpHost);
 
        var policy = new HeaderPolicyCollection()
            .AddFrameOptionsDeny()
            .AddContentTypeOptionsNoSniff()
            .AddReferrerPolicyStrictOriginWhenCrossOrigin()
            .AddCrossOriginOpenerPolicy(builder => builder.SameOrigin())
            .AddCrossOriginResourcePolicy(builder => builder.SameOrigin())
            .AddCrossOriginEmbedderPolicy(builder => builder.RequireCorp())
            .AddContentSecurityPolicy(builder =>
            {
                builder.AddObjectSrc().None();
                builder.AddBlockAllMixedContent();
                builder.AddImgSrc().Self().From("data:");
                builder.AddFormAction().Self().From(idpHost);
                builder.AddFontSrc().Self();
                builder.AddStyleSrc().Self();
                builder.AddBaseUri().Self();
                builder.AddFrameAncestors().None();
 
                // Blazor Web with AddInteractiveWebAssemblyComponents cannot use CSP nonces
                builder.AddScriptSrc()
                    .Self()
                    .UnsafeEval()   // required for Blazor WASM
                    .UnsafeInline(); // fallback for older browsers
            })
            .RemoveServerHeader()
            .AddPermissionsPolicy(builder =>
            {
                builder.AddAccelerometer().None();
                builder.AddAutoplay().None();
                builder.AddCamera().None();
                builder.AddEncryptedMedia().None();
                builder.AddFullscreen().All();
                builder.AddGeolocation().None();
                builder.AddGyroscope().None();
                builder.AddMagnetometer().None();
                builder.AddMicrophone().None();
                builder.AddMidi().None();
                builder.AddPayment().None();
                builder.AddPictureInPicture().None();
                builder.AddSyncXHR().None();
                builder.AddUsb().None();
            });
 
        if (!isDev)
        {
            policy.AddStrictTransportSecurityMaxAgeIncludeSubDomains();
        }
 
        policy.ApplyDocumentHeadersToAllResponses();
        return policy;
    }
}

Applying this policy locks down object embeds, mixed content, image and font sources, and frame ancestors the way you would want on any production app. The problem is the AddScriptSrc block, which has to allow unsafe-eval and unsafe-inline. Damien explains why: when a component runs in AddInteractiveWebAssemblyComponents mode, it cannot read the HTTP response headers, so there is no way for the Blazor runtime to pick up a CSP nonce and prove a script block is legitimate. Without that mechanism, the only way to let Blazor WASM’s own bootstrap scripts run is to relax script-src, which reopens the door to a large class of XSS attacks that a strict CSP would otherwise block.

This is not a bug you can code your way around in the app itself. It is a structural gap in how Blazor Web’s WebAssembly interactive mode currently works. If a strict script-src with nonces is a hard requirement for your application, for example because of a compliance mandate, Blazor Server or a Blazor WASM app hosted inside ASP.NET Core (the older, separate-project pattern) still support CSP nonces properly. Blazor Web only gets you there if you stick to server rendering and interactive server components and skip WebAssembly interactivity entirely.

Comparing security posture across the four Blazor hosting models

.NET now ships four distinct ways to build and host a Blazor application, and each has a different authentication and CSP story. Worth keeping straight before you pick one for a new project:

  • Blazor WASM hosted in ASP.NET Core: separate client and server projects, supports CSP nonces properly, confidential client authentication works as recommended.
  • Blazor Server: components render and run entirely on the server, confidential client authentication works as recommended, strong CSP is fully supported.
  • Blazor Web (.NET 8+): can implement a confidential client correctly, but loses proper CSP nonce support the moment you enable interactive WebAssembly components.
  • Blazor WASM standalone: runs entirely in the browser as a public client, cannot securely hold secrets, and should not be used for applications that need strong authentication guarantees.

What to watch out for before you ship this

Damien is candid in the original post that some parts of Blazor Web still feel unsettled. Session state does not automatically flow between different render modes on the same page the way you might expect coming from Blazor Server, so components that need shared auth state may need extra plumbing through something like PersistentAuthenticationStateProvider on the client, paired with a PersistingRevalidatingAuthenticationStateProvider on the server. Mixing render modes on one page can also produce visual glitches during the handoff between prerendering and interactivity, which is worth testing thoroughly with real users before launch rather than assuming it will just work.

If you are evaluating this for a production system, treat the render mode decision as a security decision, not just a UI one. Pick Blazor Server or hosted Blazor WASM if a strict CSP without unsafe-inline is non negotiable for you. Reach for Blazor Web with WebAssembly interactivity when the unified developer experience matters more than that last layer of script isolation, and compensate with other controls, like keeping third party script dependencies to a minimum and running regular dependency audits, since your CSP will not catch an XSS the way it normally would.

Leave a Reply

Discover more from Behind the Stack

Subscribe now to keep reading and get access to the full archive.

Continue reading