Securing a MudBlazor UI web application using security headers and Microsoft Entra ID

MudBlazor is one of the most widely adopted component libraries for Blazor, built on Material Design principles and popular because it gets a professional looking UI running quickly. The catch is that MudBlazor injects inline styles into the page, and Blazor WebAssembly needs its own relaxed script rules to boot up. Both of these fight against a strict Content Security Policy. This article walks through how to lock down a Blazor WASM application that uses MudBlazor, using security headers and Microsoft Entra ID as the identity provider, while being upfront about the compromises that MudBlazor forces on the CSP.

Application setup

The setup here is a Blazor WebAssembly client hosted inside an ASP.NET Core server project, which is the standard hosted Blazor WASM model. The MudBlazor NuGet package is added to the client project, and a handful of MudBlazor components (buttons, data grids, dialogs) are wired up in the UI following the MudBlazor documentation.

Running MudBlazor UI components in the hosted Blazor WASM application
Running MudBlazor UI components in the hosted Blazor WASM application

Nothing unusual so far. The interesting part starts once you try to add strict security headers on top of this, because MudBlazor’s Material Design components rely on inline style attributes to do their layout and theming, and a strict CSP blocks inline styles by default.

Why security headers matter here

Security headers protect the session of the web application against a range of browser based attacks: clickjacking, MIME sniffing, cross origin leaks, and script injection through a weak CSP. The NetEscapades.AspNetCore.SecurityHeaders package is used here to implement the headers, along with NetEscapades.AspNetCore.SecurityHeaders.TagHelpers so that CSP nonces can be applied directly in Razor markup. Two packages are added to the server project:

  • NetEscapades.AspNetCore.SecurityHeaders
  • NetEscapades.AspNetCore.SecurityHeaders.TagHelpers

A SecurityHeadersDefinitions class defines the full header policy. A CSP nonce protects the script tag, but the ‘unsafe-eval’ value has to be added to the script CSP because Blazor WebAssembly’s runtime needs it to work, and this genuinely weakens the protection the CSP would otherwise give you. Unsafe inline is added as a fallback for older browsers that do not support nonces. The style CSP goes further and allows unsafe inline outright, because MudBlazor’s components write inline styles that a nonce cannot cover.

namespace MicrosoftEntraIdMudBlazor.Server;
 
public static class SecurityHeadersDefinitions
{
    public static HeaderPolicyCollection GetHeaderPolicyCollection(bool isDev, string? idpHost)
    {
        if(idpHost == null)
        {
            throw new ArgumentNullException(nameof(idpHost));
        }
 
        var policy = new HeaderPolicyCollection()
            .AddFrameOptionsDeny()
            .AddContentTypeOptionsNoSniff()
            .AddReferrerPolicyStrictOriginWhenCrossOrigin()
            .AddCrossOriginOpenerPolicy(builder => builder.SameOrigin())
            .AddCrossOriginResourcePolicy(builder => builder.SameOrigin())
            .AddCrossOriginEmbedderPolicy(builder => builder.RequireCorp()) // remove for dev if using hot reload
            .AddContentSecurityPolicy(builder =>
            {
                builder.AddObjectSrc().None();
                builder.AddBlockAllMixedContent();
                builder.AddImgSrc().Self().From("data:");
                builder.AddFormAction().Self().From(idpHost);
                builder.AddFontSrc().Self();
                builder.AddBaseUri().Self();
                builder.AddFrameAncestors().None();
 
                builder.AddStyleSrc()
                    .UnsafeInline() // due to Mudblazor
                    .Self();
 
                builder.AddScriptSrc()
                    .WithNonce()
                    .UnsafeEval() // due to Blazor WASM
                    .UnsafeInline();
 
                // disable script and style CSP protection if using Blazor hot reload
                // if using hot reload, DO NOT deploy with an insecure CSP
            })
            .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)
        {
            // maxage = one year in seconds
            policy.AddStrictTransportSecurityMaxAgeIncludeSubDomains(
        maxAgeInSeconds: 60 * 60 * 24 * 365);
        }
 
        policy.ApplyDocumentHeadersToAllResponses();
 
        return policy;
    }
}

Beyond the CSP, this class also denies framing entirely, blocks MIME sniffing, sets a strict referrer policy, isolates the browsing context with COOP and CORP, and locks down browser permissions like camera, microphone, and geolocation that a typical business application has no business asking for. HSTS is only added when isDev is false, which is a detail worth checking in your own setup since a wrongly configured environment flag will silently skip HSTS in production.

The comment about hot reload matters in real projects. If your CSP is loosened to support Blazor hot reload during local development, make sure that configuration path is never the one that reaches a deployed environment. It’s an easy mistake to make if isDev checks are copied around without care.

The header policy is applied to every request through the middleware pipeline, using the idpHost value pulled from configuration so the form action can trust redirects back from Entra ID.

app.UseSecurityHeaders(SecurityHeadersDefinitions
    .GetHeaderPolicyCollection(env.IsDevelopment(), 
         configuration["AzureAd:Instance"]));

This single line wires up the header policy for every HTTP response the server sends. Because the nonce is generated per request, this middleware needs to run before any Razor page or view that references the tag helper for the nonce, otherwise the nonce value written into the HTML will not match what the CSP header expects.

The nonce itself is applied through the TagHelpers package, which has to be registered in the Razor view imports before it can be used on script tags.

@addTagHelper *, NetEscapades.AspNetCore.SecurityHeaders.TagHelpers

With the tag helper registered, the asp-add-nonce attribute can be added to any script tag, and the middleware fills in the matching nonce value automatically on every response.

<script asp-add-nonce src="_framework/blazor.webassembly.js"></script>
<script asp-add-nonce src="_content/MudBlazor/MudBlazor.min.js"></script>
<script asp-add-nonce src="antiForgeryToken.js"></script>

All three scripts that the application actually needs, the Blazor WASM bootstrapper, the MudBlazor JS interop file, and a small script for the anti forgery token, get the nonce attached. Any script tag without asp-add-nonce will be blocked by the CSP once unsafe-inline for scripts is tightened, so this is worth checking carefully if you add third party widgets later.

Adding Microsoft Entra ID authentication

Microsoft Entra ID protects the application here, implemented with the Microsoft.Identity.Web family of packages for the OpenID Connect client. The authentication itself follows the backend for frontend (BFF) pattern: the Blazor WASM client never sees a token directly. The server backend owns the OpenID Connect handshake and stores the session in a secure, HTTP only cookie, and the WASM client uses that cookie for its data requests to the backend.

  • Microsoft.Identity.Web
  • Microsoft.Identity.Web.UI
  • Microsoft.Identity.Web.GraphServiceClient

This is a meaningfully more secure pattern than storing access tokens in browser storage or in the WASM client’s memory, since a cookie marked HttpOnly is not reachable from JavaScript, which closes off a large class of token theft through XSS. The trade off is a bit more server side plumbing, since every downstream call now goes through the server backend rather than straight from the browser.

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

This registers the OpenID Connect client, enables token acquisition for calling a downstream API with the configured scopes, and wires up a Microsoft Graph client on top of the same token flow. AddInMemoryTokenCaches keeps things simple for a demo, but it is a real limitation for production: if the cache is in memory and the application restarts, or if you are running more than one instance behind a load balancer, the cache is gone or inconsistent while the authentication cookie is still valid. Use a distributed cache, such as Redis or a SQL backed token cache, once this leaves the prototype stage, and make sure your code handles the case where tokens are missing even though the cookie says the user is signed in.

Trade-offs worth knowing before you ship this

The honest read on this setup is that MudBlazor’s inline styles force you to allow unsafe-inline on the style CSP directive, and there is no clean workaround for that today. If your application handles sensitive data and a strict CSP is a hard requirement, weigh this against component libraries that use CSS classes rather than inline styles, or budget time for a nonce based styling approach, which is significantly more work with MudBlazor’s current architecture. Radzen and other Blazor component libraries have their own, different CSP requirements, so this is not a MudBlazor specific compromise, it is a common pattern across Blazor UI libraries that generate styles at runtime.

Similarly, unsafe-eval on the script CSP is a Blazor WASM requirement rather than a MudBlazor one, and it will show up in any hosted Blazor WASM project regardless of which component library you pick. None of this makes the setup insecure by default, but it does mean the CSP here is deliberately looser than what you would write for a plain server rendered Razor Pages application, and that is worth documenting for whoever runs your next security review.

Leave a Reply

Discover more from Behind the Stack

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

Continue reading