Implement a secure Blazor Web application using OpenID Connect and security headers

Blazor Web in .NET 8 gives you a single project model where components can render on the server, in the browser through WebAssembly, or switch between the two automatically. That flexibility is genuinely useful for building apps, but it changes how you need to think about browser level security, particularly around Content Security Policy. This article walks through securing a Blazor Web application using OpenID Connect for authentication and CSP nonces for script protection, based on Damien Bod’s BlazorWebOidc sample. OpenIddict acts as the identity provider here, and the NetEscapades.AspNetCore.SecurityHeaders package handles the security header plumbing.

Blazor server components authenticate against the OpenIddict OpenID Connect server using the confidential code flow with PKCE.
Blazor server components authenticate against the OpenIddict OpenID Connect server using the confidential code flow with PKCE.

Why CSP nonces matter for Blazor Web

A Content Security Policy that simply allow-lists script sources with wildcards or unsafe-inline gives an attacker plenty of room if they manage to inject markup into your page. A nonce based CSP is stricter: the server generates a random value for each request, sets it in the CSP header, and only scripts carrying that exact nonce attribute are allowed to run. Anything injected by an attacker will not have the correct nonce, so the browser refuses to execute it.

Blazor Web using WebAssembly rendering does not support this cleanly, because the WASM runtime needs looser script execution rules to bootstrap itself in the browser. If you care about a strict CSP, you are better off disabling WASM interactivity for this application and sticking to server rendering with SignalR circuits, which is exactly what this setup does.

Starting from the Microsoft Blazor sample

Rather than build the OIDC client plumbing from scratch, this setup starts from Microsoft’s own BlazorWebAppOidc sample from the dotnet/blazor-samples repository. That sample already implements the client profile handling, CSRF protection, and the login and logout flows, which saves a fair amount of boilerplate. From there, the two main changes are swapping out the identity provider and locking down the CSP.

Pointing the OIDC client at OpenIddict

The sample project ships wired up for Microsoft Entra ID, so the first change is to replace that with an OpenIddict client. Both sides, the client configuration in the Blazor app and the client registration inside the OpenIddict server, need to agree on the same client ID, redirect URIs, and flow type. This example uses a confidential client with authorization code flow and PKCE, which is the right choice for a server rendered app that can safely hold a client secret.

builder.Services.AddAuthentication(OIDC_SCHEME)
    .AddOpenIdConnect(OIDC_SCHEME, options =>
    {
        // From appsettings.json, keyvault, user-secrets
        // "OpenIDConnectSettings": {
        //  "Authority": "https://localhost:44318",
        //  "ClientId": "oidc-pkce-confidential",
        //  "ClientSecret": "--secret-in-key-vault-user-secrets--"
        // },
        builder.Configuration.GetSection("OpenIDConnectSettings").Bind(options);
 
        options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
        options.ResponseType = OpenIdConnectResponseType.Code;
 
        options.SaveTokens = true;
        options.GetClaimsFromUserInfoEndpoint = true;
        options.MapInboundClaims = false; // Remove Microsoft mappings
        options.TokenValidationParameters = new TokenValidationParameters
        {
            NameClaimType = "name"
        };
    })
    .AddCookie();

The Authority, ClientId, and ClientSecret come from configuration rather than being hard coded, so they can move between appsettings.json for local development and Key Vault or user secrets for anything closer to production. Setting MapInboundClaims to false is worth calling out specifically, because ASP.NET Core otherwise remaps standard OIDC claim types to legacy Microsoft claim URIs, which trips up a lot of people who then cannot find the claims they expect. You could also use the dedicated OpenIddict client packages instead of the generic OpenID Connect handler, but sticking with the built in handler keeps the setup closer to what most ASP.NET Core developers already know.

Turning off WebAssembly interactivity

Since a strict CSP and WASM interactivity do not mix well, the next step is to strip the WebAssembly render mode out of the project and run everything through interactive server components instead. This is a few small edits across Program.cs and the root layout markup, not a full rewrite.

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

This registers only the server side interactive rendering services. If you leave AddInteractiveWebAssemblyComponents in place alongside this, the app will still offer WASM as a render mode option to individual components, which defeats the purpose.

app.MapRazorComponents<App>()
    .AddInteractiveServerRenderMode()
    .AddAdditionalAssemblies(
            typeof(BlazorWebAppOidc.Client._Imports).Assembly);

AddInteractiveServerRenderMode wires the endpoint to only use server rendering, while AddAdditionalAssemblies keeps the client project’s components discoverable even though they will now render on the server. Finally, the render mode attributes in the layout need to drop WebAssembly and Auto in favour of Server explicitly.

<HeadOutlet @rendermode="InteractiveServer" />
</head>
<body>
    <Routes @rendermode="InteractiveServer" />

After this change, every interactive component in the app runs over a SignalR circuit on the server. You lose the offline capability and client side snappiness that WASM gives you, which is a real trade-off worth discussing with your team before committing to this approach on a large application.

Getting the CSP nonce into Blazor components

Here is where Blazor Web gets a bit awkward. The CSP nonce is generated by middleware and lives on the HTTP response headers, but Blazor Server components do not have direct access to the HTTP response once the SignalR circuit takes over rendering. The common workaround, used in this sample, is a CircuitHandler that captures the nonce early and persists it across the component tree using PersistentComponentState.

using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Server.Circuits;
 
namespace BlazorWebAppOidc.CspServices;
 
public class BlazorNonceService : CircuitHandler, IDisposable
{
    private readonly PersistentComponentState _state;
    private readonly PersistingComponentStateSubscription _subscription;
 
    public BlazorNonceService(PersistentComponentState state)
    {
        if (state.TryTakeFromJson("nonce", out string? nonce))
        {
            if (nonce is not null)
            {
                Nonce = nonce;
            }
            else
            {
                throw new InvalidOperationException(
                         "Nonce can't be null when provided");
            }
        }
        else
        {
            _subscription = state.RegisterOnPersisting(PersistNonce);
        }
 
        _state = state;
    }
 
    public string? Nonce { get; set; }
 
    private Task PersistNonce()
    {
        _state.PersistAsJson("nonce", Nonce);
        return Task.CompletedTask;
    }
 
    public void SetNonce(string nonce)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(nonce);
 
        if (Nonce != null)
        {
            throw new InvalidOperationException("Nonce already defined");
        }
 
        Nonce = nonce;
    }
 
    public void Dispose() => ((IDisposable)_subscription)?.Dispose();
}

On first render, the constructor tries to pick up a previously persisted nonce from component state. If none exists yet, it registers a callback to persist the current nonce once rendering finishes, so a value set later through SetNonce survives the prerender to interactive render transition. This pattern originates from Javier Calvarro Nelson’s BlazorWebNonceService sample and is worth crediting when you reuse it, since it solves a genuinely fiddly problem in a fairly small amount of code.

Next, a small piece of middleware reads the nonce that the security headers package generated and pushes it into the CircuitHandler.

namespace BlazorWebAppOidc.CspServices;
 
public class NonceMiddleware
{
    private readonly RequestDelegate _next;
 
    public NonceMiddleware(RequestDelegate next)
    {
        _next = next;
    }
 
    public async Task Invoke(HttpContext context, 
              BlazorNonceService blazorNonceService)
    {
        var success = context.Items
                  .TryGetValue("NETESCAPADES_NONCE", out var nonce);
        if (success && nonce != null)
        {
            blazorNonceService.SetNonce(nonce.ToString()!);
        }
        await _next.Invoke(context);
    }
}

NetEscapades.AspNetCore.SecurityHeaders stores the generated nonce under the NETESCAPADES_NONCE key in HttpContext.Items when you configure the CSP to use a nonce. This middleware just reads that value out and hands it to the CircuitHandler, which is scoped per circuit so each browser tab or reconnect gets its own nonce instance. Both pieces then need registering in Program.cs.

builder.Services.TryAddEnumerable(
     ServiceDescriptor.Scoped<CircuitHandler, BlazorNonceService>(sp =>
     sp.GetRequiredService<BlazorNonceService>()));
 
builder.Services.AddScoped<BlazorNonceService>();
app.UseMiddleware<NonceMiddleware>();

TryAddEnumerable registers the CircuitHandler without duplicating it if something else already registered one, and resolving it through the existing scoped instance keeps BlazorNonceService and the CircuitHandler entry pointing at the same object. Order matters for the middleware registration too; it needs to run after the security headers middleware has actually set the nonce on the context, otherwise NonceMiddleware finds nothing to read.

Configuring the security headers

With the nonce plumbing in place, the actual CSP and other browser security headers are defined in one place using NetEscapades.AspNetCore.SecurityHeaders.

namespace BlazorWebAppOidc;
 
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.AddBaseUri().Self();
                builder.AddFrameAncestors().None();
 
                builder.AddStyleSrc()
                    .UnsafeInline()
                    .Self();
 
                // due to Blazor
                builder.AddScriptSrc()
                      .WithNonce()
                      .UnsafeEval() // due to Blazor WASM
                      .StrictDynamic()
                      .OverHttps()
                      .UnsafeInline(); // only a 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)
        {
            // maxage = one year in seconds
            policy.AddStrictTransportSecurityMaxAgeIncludeSubDomains();
        }
 
        policy.ApplyDocumentHeadersToAllResponses();
 
        return policy;
    }
}

Most of this reads plainly: object-src, frame-ancestors and frame options are locked down completely, the form action is restricted so a login form can only post to your own origin or the configured identity provider host, and the permissions policy switches off camera, microphone, USB and similar device APIs the app has no business asking for. WithNonce and StrictDynamic on the script-src are the important bits for this article; StrictDynamic tells modern browsers to trust scripts loaded by an already trusted, nonced script, which is what lets Blazor’s own bootstrapping scripts run without a long allow-list of hashes.

One thing worth flagging if you copy this as-is: UnsafeEval is still present in the script-src with a comment saying it is there for Blazor WASM, even though this article’s whole point is disabling WASM. If you have genuinely removed all WASM interactivity from the app, this is a leftover you should remove, since unsafe-eval widens the policy more than a server rendered app needs. The UnsafeInline fallback on script-src is deliberately kept for older browsers that ignore nonces and strict-dynamic, but any browser that understands nonces will ignore unsafe-inline automatically, so it is safe to leave as a fallback rather than a hole.

HSTS is only added when the app is not running in development, which is a sensible default since forcing HTTPS locally over self-signed certificates causes more friction than it is worth. Registering the policy in the pipeline is a single line, placed as early as possible so headers apply to every response, including error pages and static files.

app.UseSecurityHeaders(
    SecurityHeadersDefinitions.GetHeaderPolicyCollection(
        app.Environment.IsDevelopment(),
        app.Configuration["OpenIDConnectSettings:Authority"]));

Passing the OIDC Authority in as the idpHost means the form-action directive automatically tracks whatever identity provider you configure, so you are not maintaining that URL in two places.

Wiring the nonce onto the Blazor bootstrap script

The last piece is making sure the nonce actually reaches the markup. In the root App component, a small code block reads the nonce out of HttpContext.Items during the initial server render and pushes it into BlazorNonceService, and the script tag that loads blazor.web.js carries that nonce as an attribute so the CSP allows it to execute.

@code
{
    [CascadingParameter] HttpContext Context { get; set; } = default!;
 
    protected override void OnInitialized()
    {
        var nonce = GetNonce();
        if (nonce != null)
        {
            BlazorNonceService.SetNonce(nonce);
        }
    }
 
    public string? GetNonce()
    {
        if (Context.Items.TryGetValue("nonce", out var item) 
            && item is string nonce)
        {
            return nonce;
        }
 
        return null;
    }
}

The corresponding markup in the same file references the nonce on the script tag, something like script src=”_framework/blazor.web.js” nonce=”@BlazorNonceService.Nonce”. Without this attribute matching the nonce in the response header, the browser blocks Blazor’s own bootstrap script and the app never becomes interactive, which is a confusing failure mode if you have not seen it before; the page loads but nothing responds to clicks.

Testing it and deciding if this trade-off is right for you

Once wired up, start the OpenIddict identity provider and the Blazor app together and walk through a full login, a page with interactive components, and a logout. Open your browser’s developer tools and check the console for CSP violation warnings; a clean run should show none, and any violation tells you exactly which resource still needs allow-listing.

Before adopting this pattern on a real project, weigh the trade-off honestly. Disabling WASM interactivity gives up client side rendering, so every interaction round-trips over SignalR, which matters on latency sensitive or offline-capable apps. What you get in return is a genuinely strict CSP with nonces and strict-dynamic instead of a looser policy full of unsafe-inline and wildcard sources. For internal line of business apps and anything handling sensitive data, that trade-off usually favours security. For public facing apps where WASM’s offline support or reduced server load matters more, you may want to accept a somewhat looser CSP instead of dropping WASM entirely.

One area the sample does not cover in depth is what happens to the nonce across a SignalR circuit reconnect, for example when a laptop goes to sleep or a mobile browser loses network briefly. PersistentComponentState only covers the initial prerender to interactive transition, not a full circuit reconnect after a disconnect, so it is worth testing that scenario specifically on your own app rather than assuming the nonce handling covers it automatically.

Leave a Reply

Discover more from Behind the Stack

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

Continue reading