In modern web development, security isn’t an afterthought; it’s a foundational requirement. One of the most effective and straightforward ways to harden your ASP.NET Core application is by using HTTP security headers. These headers instruct the browser on how to behave, mitigating common attacks like Cross-Site Scripting (XSS), clickjacking, and protocol downgrade attacks.

While you can add these headers in various places, the most robust and maintainable approach is to create a single, centralized middleware. This ensures every response from your application is consistently protected.

In this post, we’ll build a configurable security headers middleware from scratch that manages Content-Security-Policy (CSP), HTTP Strict-Transport-Security (HSTS), and other essential headers.


Why a Centralized Middleware?

You might be tempted to sprinkle Response.Headers.Add(...) in your controllers or use separate app.Use...() calls for each header in Program.cs. However, a centralized approach offers significant advantages:

  • Consistency: Every single endpoint gets the same baseline protection without fail. You eliminate the risk of forgetting to secure a new API or page.
  • Single Point of Configuration: All your security header policies live in one place. Need to tighten your CSP? You only have one file to edit.
  • Maintainability: As security standards evolve, updating your policies is trivial. You don’t have to hunt down configurations scattered across the project.
  • Clean Code: It keeps your Program.cs file cleaner and focused on the high-level application pipeline.

Understanding the Key Security Headers

Before we write code, let’s quickly review the headers our middleware will handle:

  • Content-Security-Policy (CSP): This is the powerhouse for preventing XSS. It’s a whitelist that tells the browser which sources of content (scripts, styles, images, etc.) are trusted and allowed to load. Any content from a non-whitelisted source is blocked.
  • HTTP Strict-Transport-Security (HSTS): This header tells the browser that it should only ever communicate with your site using HTTPS, never HTTP. It’s a crucial defense against man-in-the-middle attacks.
  • X-Frame-Options: This header prevents your site from being embedded in an <frame>, <iframe>, <embed>, or <object>, which is the primary vector for clickjacking attacks.
  • X-Content-Type-Options: Setting this to nosniff prevents the browser from trying to guess the content type of a resource, which can stop attacks that try to trick the browser into executing a seemingly harmless file (like an image) as a script.
  • Referrer-Policy: This controls how much referrer information is included with requests, enhancing user privacy.
  • Permissions-Policy: The successor to Feature-Policy, this allows you to selectively enable or disable browser features and APIs (like camera, microphone, geolocation) on your site.

Callout: The CSP Learning Curve

Content-Security-Policy is incredibly powerful, but it can also be complex to configure correctly. A policy that’s too strict can break your site’s functionality, while one that’s too loose offers little protection. My advice: start with a basic, restrictive policy and incrementally add sources as you test your application. Use your browser’s developer console; it will report all CSP violations, telling you exactly what was blocked.

Step 1: Create the Configuration Model

First, let’s create a strongly-typed options class to hold our configuration. This allows us to manage our policies neatly in appsettings.json.

SecurityHeadersOptions.cs

public sealed class SecurityHeadersOptions
{
    public string? ContentSecurityPolicy { get; set; }
    public string? XFrameOptions { get; set; }
    public string? XContentTypeOptions { get; set; }
    public string? ReferrerPolicy { get; set; }
    public string? PermissionsPolicy { get; set; }
}

Now, we can define our policies in our application settings.

appsettings.json

{
  // ... other settings
  "SecurityHeaders": {
    "ContentSecurityPolicy": "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self'; font-src 'self'; object-src 'none'; frame-ancestors 'none'; upgrade-insecure-requests;",
    "XFrameOptions": "DENY",
    "XContentTypeOptions": "nosniff",
    "ReferrerPolicy": "no-referrer",
    "PermissionsPolicy": "camera=(), microphone=(), geolocation=()"
  }
}

This configuration is a good starting point: it allows content only from our own domain ('self'), blocks all framing, prevents content-type sniffing, and disables common device APIs.

Step 2: Build the Middleware

Next is the core of our solution: the middleware itself. It’s a simple class that intercepts the HTTP context, adds the headers, and then passes the request along the pipeline.

We’ll use a primary constructor (a modern C# feature) for cleaner syntax and inject IOptions<SecurityHeadersOptions> to access our configuration.

SecurityHeadersMiddleware.cs

using Microsoft.Extensions.Options;
using Microsoft.Net.Http.Headers;

public sealed class SecurityHeadersMiddleware(RequestDelegate next, IOptions<SecurityHeadersOptions> options)
{
    private readonly SecurityHeadersOptions _options = options.Value;

    public async Task InvokeAsync(HttpContext context)
    {
        // Before the response is sent, add the headers
        AddHeaderIfNotExists(context, HeaderNames.ContentSecurityPolicy, _options.ContentSecurityPolicy);
        AddHeaderIfNotExists(context, HeaderNames.XFrameOptions, _options.XFrameOptions);
        AddHeaderIfNotExists(context, HeaderNames.XContentTypeOptions, _options.XContentTypeOptions);
        AddHeaderIfNotExists(context, HeaderNames.ReferrerPolicy, _options.ReferrerPolicy);
        AddHeaderIfNotExists(context, "Permissions-Policy", _options.PermissionsPolicy);

        // Call the next middleware in the pipeline
        await next(context);
    }

    private static void AddHeaderIfNotExists(HttpContext context, string key, string? value)
    {
        if (!string.IsNullOrEmpty(value) && !context.Response.Headers.ContainsKey(key))
        {
            context.Response.Headers[key] = value;
        }
    }
}

The AddHeaderIfNotExists helper method is important. It ensures our middleware doesn’t override a header that might have been set for a specific reason later in the pipeline.

Step 3: Create an Extension Method for Easy Registration

To make our middleware easy to use, we’ll create a simple extension method for IApplicationBuilder. This provides a clean app.UseSecurityHeaders() syntax for our Program.cs.

SecurityHeadersMiddlewareExtensions.cs

public static class SecurityHeadersMiddlewareExtensions
{
    public static IApplicationBuilder UseSecurityHeaders(this IApplicationBuilder app)
    {
        return app.UseMiddleware<SecurityHeadersMiddleware>();
    }
}

Step 4: Wire Everything Up in Program.cs

Finally, we just need to register our configuration and add the middleware to the request pipeline. With ASP.NET Core minimal APIs, this is incredibly concise.

Program.cs

var builder = WebApplication.CreateBuilder(args);

// 1. Configure the strongly-typed options
builder.Services.Configure<SecurityHeadersOptions>(
    builder.Configuration.GetSection("SecurityHeaders"));

// Add other services...
builder.Services.AddControllers();

var app = builder.Build();

// --- Middleware Pipeline ---

// 2. Add HSTS middleware (this is environment-aware)
// IMPORTANT: HSTS should only be used in production environments over HTTPS.
if (!app.Environment.IsDevelopment())
{
    // The default HSTS value is 30 days. You may want to change this for production scenarios, see [https://aka.ms/aspnetcore-hsts](https://aka.ms/aspnetcore-hsts).
    app.UseHsts();
}

app.UseHttpsRedirection();

// 3. Add our custom security headers middleware
// This should be placed early in the pipeline to ensure headers are added to all responses.
app.UseSecurityHeaders();

app.UseRouting();

app.UseAuthorization();

app.MapControllers();

app.Run();

A quick note on app.UseHsts(): I’ve kept the built-in HSTS middleware separate because it has environment-aware logic that is best not to replicate. Our custom middleware focuses on the other static headers, creating a clean separation of concerns. It should be placed early in the pipeline to ensure it runs for nearly all responses, including those for errors or static files.

Conclusion

And that’s it! With one configuration file and a simple middleware class, you’ve created a centralized, maintainable system for applying crucial HTTP security headers to your entire ASP.NET Core application.

This pattern not only hardens your application against common web vulnerabilities but also makes your security posture explicit and easy to manage. As you continue to develop your application, you can now modify your security policies from a single, trusted source, confident that the changes will apply globally. This is a small investment in code that pays huge dividends in security and peace of mind.