For a long time, Blazor teams faced an awkward choice. Run a strict Content Security Policy in production and a much weaker one during development, or fight Visual Studio’s hot reload and browser link tooling every single day. Most teams picked the weaker development policy, because shipping mattered more than getting the headers right on day one. That gap between dev and prod is exactly the kind of thing that comes back to bite you during a security review.
Damien Bod documented a fix to this problem on his blog, and it is worth walking through in detail. The underlying issue, and the fix, apply to almost any ASP.NET Core project that combines hot reload with a Content Security Policy, not just Blazor. If you are running Blazor Server or Blazor WebAssembly behind a BFF (Backend for Frontend) architecture, this directly affects how you set up your local environment.
Why development and production should share the same CSP
The rule of thumb is simple: your development environment should look as close to production as possible. The more the two differ, the more surprises you get after deployment. This applies to HTTPS, it applies to the URLs you allow scripts and styles to load from, and it applies just as strongly to your Content Security Policy.
When developers are allowed to run with a relaxed CSP locally, unsafe scripts, inline styles, and references to random CDNs slip into the codebase without anyone noticing. Removing them later is expensive, because by then features depend on them. Keeping the same CSP in both environments, apart from the actual host URLs, forces the team to fix violations as they write the code instead of after a penetration test flags them.
What broke before the Visual Studio fix
Before this fix landed, applying a proper CSP locally broke hot reload. Visual Studio’s browser link feature injects a small script and some inline styles into the page to support live reload and debugging. With a strict script-src and style-src in place, the browser refused to run that injected code, and the console filled up with CSP violation warnings.
Faced with that, most teams weakened the CSP just for local development, usually by adding unsafe-inline or dropping style-src entirely. That is a bad trade. You end up debugging a security header that behaves completely differently from what actually ships. The fix to Visual Studio’s tooling, tracked in the developer community as browser link CSP support for .NET 7, removed this restriction so hot reload works even with a strict policy switched on.

After the fix, you can develop and deploy with the same CSP definition, and hot reload keeps working while that policy is active. That is a small change on paper, but it removes a genuine excuse for weakening security headers during development.
Defining the security headers
The most practical way to add these headers to a Blazor application is the NetEscapades.AspNetCore.SecurityHeaders NuGet package, along with its companion package for CSP nonce support in Razor tag helpers. Add it to the server part of the Blazor application. Most modern secure Blazor setups use the BFF security architecture, where the server handles authentication and tokens never reach the browser, so the security headers on the server side matter a great deal.
Once the package is installed, you define the policy in one place. Here is what that definition looks like in practice.
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();
// due to Blazor
builder.AddScriptSrc()
.WithNonce()
.UnsafeEval() // due to Blazor WASM
.StrictDynamic()
.UnsafeInline(); // fallback for older browsers when the nonce is used
})
.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;
}
}
This method returns a HeaderPolicyCollection that gets applied to every response, static files included, which is a detail most sample projects skip. A few directives deserve a closer look because they are where teams usually get CSP wrong.
- object-src is set to none, since Blazor applications rarely need Flash-era plugin content and this closes off a common XSS vector.
- form-action is restricted to self and the identity provider host, so a compromised page cannot silently post form data somewhere else.
- script-src combines a nonce, strict-dynamic and unsafe-eval, with unsafe-inline kept only as a fallback for browsers that ignore nonce and strict-dynamic together.
- HSTS is only added when isDev is false, so local HTTPS certificates do not trigger browser HSTS caching during development.
The unsafe-eval keyword on script-src is not a mistake. Blazor WebAssembly still needs it internally, and there is presently no way to run Blazor WASM under a CSP without it. Strict-dynamic and the nonce reduce the practical risk from unsafe-inline, since browsers that understand strict-dynamic ignore unsafe-inline entirely, but this is a real compromise worth documenting for whoever runs your next security review.
Wiring the headers into the request pipeline
Defining the policy is only half the job. You also need to call UseSecurityHeaders early in the pipeline, before static files and authentication middleware run, so every response carries the headers.
var app = builder.Build();
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
app.UseWebAssemblyDebugging();
}
else
{
app.UseExceptionHandler("/Error");
}
app.UseSecurityHeaders(
SecurityHeadersDefinitions.GetHeaderPolicyCollection(env.IsDevelopment(),
configuration["AzureAd:Instance"]));
app.UseHttpsRedirection();
app.UseBlazorFrameworkFiles();
app.UseStaticFiles();
app.UseRouting();
app.UseNoUnauthorizedRedirect("/api");
app.UseAuthentication();
app.UseAuthorization();
Notice that UseSecurityHeaders runs before UseBlazorFrameworkFiles and UseStaticFiles. That ordering is what makes the headers apply to the Blazor WASM framework files and static assets, not only to the HTML page. If you place it after static files middleware, you will find your CSP header missing from a good chunk of your responses and spend an afternoon wondering why the browser is not enforcing the policy you configured.
What is still not ideal
This setup is a solid improvement, not a perfect one. The current Blazor runtime, across the .NET 7 and .NET 8 preview versions this fix was tested against, still requires unsafe-eval for WebAssembly to function, so a genuinely strict script-src without any eval allowance is not possible yet. That is a Blazor runtime limitation, not something you can configure your way around at the CSP layer.
It would be a meaningful improvement if a future Blazor release removed the WASM dependency on eval, since that is the one directive in this policy an attacker could still try to abuse. Until then, keep the nonce and strict-dynamic combination in place, since it does limit exposure even with unsafe-eval present, and treat this as a known, documented trade-off rather than an oversight.
Practical takeaways
If you are setting this up on a new project, do not wait until the week before a security audit to add a CSP. Add NetEscapades.AspNetCore.SecurityHeaders on day one and run development against the same policy you plan to ship. The Visual Studio fix described here means you no longer have a technical reason to skip this step.
Use a tool like the hash generator at report-uri.com when you need to allow a specific inline script or style without opening up unsafe-inline broadly. And if your Blazor app talks to an identity provider, keep the BFF pattern in mind: tokens stay on the server, the browser only holds a session cookie, and your CSP has far less to protect against because there is no access token sitting in local storage for a script injection to steal.
Leave a Reply