Mert Özen Further With Every Line
Security ASP.NET Core Security — #04

Emitting Security Headers and Nonce-Based CSP from ASP.NET Core: HSTS Preload, COOP/COEP/CORP, Permissions-Policy and a Reporting Endpoint

Mert Özen Nov 8, 2026 11 min 2 views
Emitting Security Headers and Nonce-Based CSP from ASP.NET Core: HSTS Preload, COOP/COEP/CORP, Permissions-Policy and a Reporting Endpoint

A nonce-based CSP changes on every request and depends on the response type, so it belongs in the app rather than Nginx, and the HTML must not be cached at any layer. We build one ASP.NET Core middleware with separate API and HTML header sets, nonce injection into Angular's index.html, Report-Only with a size- and rate-limited reporting endpoint, the HSTS preload risk and COOP/COEP/CORP, with real header output.

Version note: The code in this post was built and run with .NET 10 (ASP.NET Core 10.0.13 runtime); the package example ran on NetEscapades.AspNetCore.SecurityHeaders 1.3.1.

Guess First

Bookstore's Angular app is served from ASP.NET Core, and we wrote a middleware that adds a CSP (Content Security Policy) header to every response. The security team wants nonces on scripts, so we generate a new nonce per request and put it both in the header and in index.html. Everything worked in testing. In production a CDN (Content Delivery Network) was put in front, and a week later a security scan reported that every user sees the same nonce. How can that be? Second question: if both Nginx and the app send a Content-Security-Policy header for the same page, which one does the browser enforce?

We introduced what the headers do in the post "OWASP A02: Security Misconfiguration" of the Web Security series, which covers the OWASP (Open Worldwide Application Security Project) Top 10, and in the ASP.NET Core Web API (Application Programming Interface) series post on CORS (Cross-Origin Resource Sharing) and security headers. This post is about emitting the headers correctly from ASP.NET Core: a single middleware, different sets for API and HTML (HyperText Markup Language), a per-request nonce, a reporting endpoint and the HSTS (HTTP Strict Transport Security) preload decision. The content of the policy, meaning which directives to write for an Angular app, autoCsp and Trusted Types, is covered in the third post of the Frontend Security series.

Nginx or the App?

The first decision is where the header is produced. The rule is simple: headers whose value depends on the request or the response belong in the app; headers that are identical on every response can live at the edge if you like. A nonce-based CSP changes on every request, differs depending on whether the response is HTML or JSON (JavaScript Object Notation) and must match the value in index.html, so it has to be produced in the app. HSTS, on the other hand, is constant for the whole domain and naturally sits wherever TLS (Transport Layer Security) terminates. Headers and rate limiting on the Nginx side are covered in the seventh post of the Web Servers and Hosting series.

What matters is that each header is produced in exactly one place. Here is the answer to the second opening question: when the browser sees several Content-Security-Policy headers, it enforces all of them at once; a resource loads only if every policy allows it. A forgotten old script-src 'self' policy in Nginx silently narrows the app's nonce-based policy and produces a "sometimes it doesn't work" situation that is very hard to debug.

One Middleware, Two Header Sets

A JSON API endpoint and the fallback serving Angular's index.html have different needs. We decide which set to apply when the response starts, after Content-Type is known:

public sealed class CspNonce
{
    public string Value { get; } = Convert.ToBase64String(RandomNumberGenerator.GetBytes(16));
}

public sealed class SecurityHeadersMiddleware(RequestDelegate next, IConfiguration configuration)
{
    private readonly bool _reportOnly = configuration.GetValue<bool>("SecurityHeaders:CspReportOnly");

    public Task InvokeAsync(HttpContext context, CspNonce nonce)
    {
        context.Response.OnStarting(() =>
        {
            IHeaderDictionary headers = context.Response.Headers;
            headers.XContentTypeOptions = "nosniff";
            headers["Cross-Origin-Resource-Policy"] = "same-origin";

            bool isHtml = context.Response.ContentType?.StartsWith("text/html", StringComparison.OrdinalIgnoreCase) == true;
            if (!isHtml)
            {
                // if a JSON response is opened as a document, nothing may load
                headers.ContentSecurityPolicy = "default-src 'none'; frame-ancestors 'none'";
                headers["Referrer-Policy"] = "no-referrer";
                return Task.CompletedTask;
            }

            string csp = string.Join("; ",
                "default-src 'self'",
                $"script-src 'self' 'nonce-{nonce.Value}'",
                $"style-src 'self' 'nonce-{nonce.Value}'",
                "img-src 'self' data:",
                "connect-src 'self'",
                "object-src 'none'",
                "base-uri 'self'",
                "form-action 'self'",
                "frame-ancestors 'none'",
                "report-uri /csp-reports",
                "report-to csp-endpoint");
            headers[_reportOnly ? "Content-Security-Policy-Report-Only" : "Content-Security-Policy"] = csp;
            headers["Reporting-Endpoints"] = "csp-endpoint=\"/csp-reports\"";
            headers["Referrer-Policy"] = "strict-origin-when-cross-origin";
            headers["Cross-Origin-Opener-Policy"] = "same-origin";
            headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=(), payment=(self)";
            return Task.CompletedTask;
        });

        return next(context);
    }
}

OnStarting matters: when the middleware runs, the endpoint has not written Content-Type yet. The callback runs just before the headers are sent to the client, so it classifies responses from both endpoints and the static file middleware correctly. We put the middleware at the very start of the pipeline so that error pages and 404s get headers too. The nonce is a 16-byte (128-bit) cryptographically random value; because CspNonce is registered as scoped, the middleware and the endpoint see the same value within one request. The set needed for API responses is very small: nosniff, a CSP that loads nothing, and no-referrer. Defining script-src on a JSON response is pointless, but if someone opens the JSON URL (Uniform Resource Locator) directly in the browser, default-src 'none' guarantees that nothing runs in that document.

A Per-Request Nonce and Angular's index.html

Angular reads the nonce from the root element carrying the ngCspNonce attribute and puts the same nonce on the <style> elements it adds at runtime. We put a placeholder into the index.html from the build output:

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Bookstore</title>
  <base href="/en/">
  <link rel="stylesheet" href="styles.css">
</head>
<body>
  <app-root ngCspNonce="__CSP_NONCE__"></app-root>
  <script src="main.js" type="module"></script>
</body>
</html>
builder.Services.AddScoped<CspNonce>();

string indexTemplate = File.ReadAllText(Path.Combine(builder.Environment.WebRootPath, "index.html"));

var app = builder.Build();

app.UseMiddleware<SecurityHeadersMiddleware>();
if (!app.Environment.IsDevelopment())
{
    app.UseHsts();
}

app.UseStaticFiles();
app.UseRateLimiter();

app.MapGet("/api/books", () => new[] { new { id = 1, title = "Dune" } });

app.MapFallback((HttpContext context, CspNonce nonce) =>
{
    context.Response.Headers.CacheControl = "no-store";
    return Results.Content(indexTemplate.Replace("__CSP_NONCE__", nonce.Value), "text/html; charset=utf-8");
});

We do not leave index.html to the static file middleware; it would serve the file as is, placeholder included. The template is read once at startup, and each request only does a Replace. The real headers of the HTML and API responses:

$ curl -s -i http://127.0.0.1:19320/
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Cache-Control: no-store
X-Content-Type-Options: nosniff
Cross-Origin-Resource-Policy: same-origin
Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self' 'nonce-Z1R4vM573EoLTLBpfn9jWA=='; style-src 'self' 'nonce-Z1R4vM573EoLTLBpfn9jWA=='; img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; report-uri /csp-reports; report-to csp-endpoint
Reporting-Endpoints: csp-endpoint="/csp-reports"
Referrer-Policy: strict-origin-when-cross-origin
Cross-Origin-Opener-Policy: same-origin
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(self)

$ curl -s -i http://127.0.0.1:19320/api/books
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
X-Content-Type-Options: nosniff
Cross-Origin-Resource-Policy: same-origin
Content-Security-Policy: default-src 'none'; frame-ancestors 'none'
Referrer-Policy: no-referrer

In two consecutive requests the ngCspNonce values were 82p5lAEFCu+a/WPFju7jjg== and p6BDw6kxWZAyouqiD8nxFA==. That is also the answer to the opening question: if the CDN caches index.html, the nonce generated for the first user is handed to everyone, in both the HTML and the header. The nonce is no longer an unpredictable value; anyone able to inject a script into the page reads it from the source and uses it. That is why Cache-Control: no-store is not optional. CDNs sometimes run rules that ignore the origin's header; set the CDN rule for HTML to "do not cache" as well, and verify it.

Starting with Report-Only and a Reporting Endpoint

Putting an enforced CSP straight onto an existing app is the fastest way to break something on the first deploy. The Content-Security-Policy-Report-Only header blocks nothing and only reports violations. We tell the browser where to send reports in two ways: the newer Reporting API works with the Reporting-Endpoints header and the report-to directive, while the older report-uri remains for browsers without Reporting API support. The two formats send different bodies, so the endpoint accepts both:

public static async Task<IResult> ReceiveAsync(HttpRequest request, ILoggerFactory loggerFactory)
{
    ILogger logger = loggerFactory.CreateLogger("Bookstore.CspReports");
    string contentType = request.ContentType ?? "";
    if (!contentType.StartsWith("application/reports+json", StringComparison.OrdinalIgnoreCase)
        && !contentType.StartsWith("application/csp-report", StringComparison.OrdinalIgnoreCase))
    {
        return Results.StatusCode(StatusCodes.Status415UnsupportedMediaType);
    }

    using JsonDocument document = await JsonDocument.ParseAsync(request.Body, cancellationToken: request.HttpContext.RequestAborted);
    IEnumerable<JsonElement> reports = document.RootElement.ValueKind == JsonValueKind.Array
        ? document.RootElement.EnumerateArray().Take(MaxReportsPerRequest).Select(r => r.GetProperty("body"))
        : [document.RootElement.GetProperty("csp-report")];

    foreach (JsonElement report in reports)
    {
        logger.LogWarning("CSP violation: {Directive} blocked {Blocked} on {Document} ({Disposition})",
            Read(report, "effectiveDirective", "effective-directive"),
            Read(report, "blockedURL", "blocked-uri"),
            Read(report, "documentURL", "document-uri"),
            Read(report, "disposition", "disposition"));
    }

    return Results.NoContent();
}
builder.Services.AddRateLimiter(options =>
{
    options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
    options.AddPolicy("csp-reports", context => RateLimitPartition.GetFixedWindowLimiter(
        context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
        _ => new FixedWindowRateLimiterOptions { PermitLimit = 10, Window = TimeSpan.FromMinutes(1), QueueLimit = 0 }));
});

app.MapPost("/csp-reports", CspReports.ReceiveAsync)
    .WithMetadata(new RequestSizeLimitAttribute(16 * 1024))
    .RequireRateLimiting("csp-reports");

This endpoint is unauthenticated, open to the internet and its content is entirely under the client's control. So it has defenses: the body is capped at 16 KB, 10 requests per minute per IP (Internet Protocol) address, at most 20 reports processed per request, and every field written to the log is truncated to 200 characters. I sent both formats and two bad requests to the local app:

reports+json: 204
csp-report: 204
text/plain: 415
20 KB body: 413
204 204 204 204 204 204 429 429

warn: Bookstore.CspReports[0]
      CSP violation: script-src-elem blocked inline on https://bookstore.test/checkout (report)
warn: Bookstore.CspReports[0]
      CSP violation: style-src-elem blocked https://cdn.example.com/theme.css on https://bookstore.test/ (report)

One detail caught my eye: the 413 came back correctly, but Kestrel wrote An unhandled exception was thrown by the application and BadHttpRequestException: Request body too large. The max request body size is 16384 bytes. to the log at fail level. Exceeding the body size surfaces as an exception while the body is being read. Someone sending large bodies to this endpoint can fill your error log; that is why it matters that the rate limit runs before the size check. Collect reports for a week or two, fix the legitimate violations (a forgotten analytics script, an inline style), then set CspReportOnly to false. You can also send both at once: the old policy enforced and the new one in report-only mode.

HSTS and the Hard-to-Undo Preload

HSTS tells the browser "only come back to this domain over HTTPS (HTTP Secure)". In ASP.NET Core UseHsts does this, but only on HTTPS requests and, by default, not for localhost, 127.0.0.1 and [::1]:

builder.Services.AddHsts(options =>
{
    options.MaxAge = TimeSpan.FromDays(365);
    options.IncludeSubDomains = true;
    options.Preload = false;
});
== hsts https bookstore.test
HTTP/2 200
strict-transport-security: max-age=31536000; includeSubDomains
== hsts https localhost
HTTP/2 200
== hsts http
HTTP/1.1 200 OK

A common mistake when testing HSTS locally is not seeing the header and assuming the middleware does not work; localhost is excluded on purpose. Preload = true adds preload to the header and makes the domain eligible to apply, through hstspreload.org, to the list built into browsers. Because the list ships with browser releases, getting off it can take months; in the meantime, thanks to includeSubDomains, every subdomain that can speak nothing but HTTP (Hypertext Transfer Protocol), such as an old intranet app or a printer panel, becomes unreachable. Turn on preload only after you have an inventory of all subdomains. Microsoft's documentation also recommends starting MaxAge at a few hours, no more than a day, in a production environment moving to HTTPS for the first time, and raising it (commonly to a year) once you trust your HTTPS setup. If MaxAge is not set, the default is 30 days.

COOP, COEP, CORP and Permissions-Policy

These three headers are the cross-origin isolation family that the OWASP HTTP Headers Cheat Sheet also covers one by one. CORP (Cross-Origin-Resource-Policy) answers "who may load me" for a resource: with same-origin we stop other sites from pulling our images and JSON through <img> or <script>. COOP (Cross-Origin-Opener-Policy) same-origin cuts off another origin that opened our page with window.open from reaching it through window.opener. COEP (Cross-Origin-Embedder-Policy) requires every cross-origin resource the page loads to opt in explicitly through CORP or CORS. Together, COOP and COEP make the page "cross-origin isolated" and unlock APIs such as SharedArrayBuffer. Bookstore does not need that, so we do not send COEP; adding require-corp without thinking breaks a payment widget or an external font that lacks a CORP header.

Permissions-Policy turns browser features off for the page and its iframes: camera, microphone and geolocation are off, and the Payment Request API is open only to our own origin. frame-ancestors 'none' replaces the old X-Frame-Options against clickjacking; if you must support very old browsers, send both.

A Ready-Made Package: NetEscapades.AspNetCore.SecurityHeaders

Instead of writing all this by hand you can use Andrew Lock's NetEscapades.AspNetCore.SecurityHeaders package. The 1.x releases support per-endpoint policies:

using NetEscapades.AspNetCore.SecurityHeaders;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddSecurityHeaderPolicies()
    .SetDefaultPolicy(policy => policy.AddDefaultApiSecurityHeaders())
    .AddPolicy("Spa", policy => policy
        .AddDefaultSecurityHeaders()
        .AddContentSecurityPolicy(csp =>
        {
            csp.AddDefaultSrc().Self();
            csp.AddScriptSrc().Self().WithNonce();
            csp.AddStyleSrc().Self().WithNonce();
            csp.AddObjectSrc().None();
            csp.AddBaseUri().Self();
            csp.AddFrameAncestors().None();
        }));

var app = builder.Build();

app.UseSecurityHeaders();

app.MapGet("/api/books", () => new[] { new { id = 1, title = "Dune" } });

app.MapGet("/", (HttpContext context) =>
        Results.Content($"<app-root ngCspNonce=\"{context.GetNonce()}\"></app-root>", "text/html"))
    .WithSecurityHeadersPolicy("Spa");

app.Run();
Content-Security-Policy: script-src 'self' 'nonce-zKm4yU3nPx1MvdCsUfRI/NBoZoicYxF+1nW7ojrgt9E='; style-src 'self' 'nonce-zKm4yU3nPx1MvdCsUfRI/NBoZoicYxF+1nW7ojrgt9E='; default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: credentialless
Cross-Origin-Resource-Policy: same-site

Watch the package defaults: AddDefaultSecurityHeaders adds COEP as credentialless, and the API set adds it as require-corp; test for the widget problem described above for that reason. The advantage of writing it by hand is that you know every line; the advantage of the package is per-endpoint policy selection and a well-tested builder API. For a single SPA (single-page application) plus an API like Bookstore, a hand-written 40-line middleware is enough.

Production Notes

Verify the headers automatically on every deploy. Checking the headers of / and /api/books in an integration test catches someone reordering the middleware or adding a second CSP. Watch the reporting endpoint's log as a metric: a sudden rise in reports after switching from Report-Only to enforced mode means either a new deploy broke the policy or someone is trying to inject a script into the page.

CSP reports are full of noise from browser extensions; count reports whose blockedURL starts with schemes like chrome-extension separately. Never show reports directly in an HTML panel: the content is fully client-controlled and can turn into XSS (cross-site scripting) in your log viewer.

Common Pitfalls

Caching nonce-carrying HTML: a CDN, a reverse proxy cache or ASP.NET Core's output caching hands the same nonce to everyone. HTML responses need no-store.

Producing the same header in two layers. Two CSPs are enforced together and only resources both allow are loaded; of two HSTS headers, according to RFC (Request for Comments) 6797, the HSTS specification, the browser processes only the first, and which one comes first depends on the order of the layers.

Leaving 'unsafe-inline' alongside a nonce. Browsers supporting CSP Level 2 and later ignore 'unsafe-inline' when a nonce is present, but old browsers that do not understand nonces apply 'unsafe-inline'; the policy's strength then varies by browser. The details are in the Frontend Security series.

Enabling HSTS in Development. It seems harmless because localhost is excluded, but if you work with a test domain (bookstore.test), your browser will try to open that domain only over HTTPS for a year.

A Small Experiment

★☆☆ Set CspReportOnly to false, add an inline <script> without a nonce to index.html, and find the violation message in the browser console and the report in the reporting endpoint's log.

★★☆ Add a check to the middleware so that it adds CSP only if none is present: if the response already has a Content-Security-Policy (added by another middleware or the endpoint), do not overwrite it and log a warning. Verify it with an integration test.

★★★ Change the reporting endpoint to log the same violation at most once a minute: count repeats with a small memory cache keyed on the effectiveDirective and blockedURL pair and write a per-minute summary.

The headers told the browser what not to do. In the next post we look at the abuse of the cases where the browser sends cookies automatically, that is CSRF (cross-site request forgery), and take a deep look at ASP.NET Core's antiforgery system.