Security headers are the cheapest security improvement you can make. A few lines of middleware can prevent clickjacking, MIME-type sniffing, information leakage, and more. Yet most ASP.NET Core applications ship without them. Let's fix that.

The Essential Headers

Here's a middleware that adds the headers every application should have:

Example.cs
app.Use(async (context, next) =>
{
    var headers = context.Response.Headers;

    // Prevent MIME-type sniffing
    headers.Append("X-Content-Type-Options", "nosniff");

    // Prevent clickjacking
    headers.Append("X-Frame-Options", "DENY");

    // Control referrer information
    headers.Append("Referrer-Policy", "strict-origin-when-cross-origin");

    // Restrict browser features
    headers.Append("Permissions-Policy",
        "camera=(), microphone=(), geolocation=(), payment=()");

    // Prevent XSS filter bypass (legacy, but still useful)
    headers.Append("X-XSS-Protection", "0");

    // Remove server identification
    headers.Remove("Server");
    headers.Remove("X-Powered-By");

    await next();
});

Let's examine each one.

X-Content-Type-Options

X-Content-Type-Options: nosniff

Without this header, browsers may "sniff" the MIME type of a response, ignoring the declared Content-Type. An attacker could upload a file with a .jpg extension containing JavaScript. If the browser sniffs it as text/html, the script executes. nosniff tells the browser to trust the Content-Type header and nothing else.

X-Frame-Options

X-Frame-Options: DENY

This prevents your pages from being embedded in <iframe>, <frame>, or <object> elements on other sites. Without it, an attacker can overlay your page with invisible elements (clickjacking) — the user thinks they're clicking a button on the attacker's page, but they're actually clicking something on yours.

DENY blocks all framing. SAMEORIGIN allows framing by pages on the same origin. For more granular control, use CSP's frame-ancestors directive instead.

Referrer-Policy

Referrer-Policy: strict-origin-when-cross-origin

Controls how much URL information is sent in the Referer header when navigating away from your site. strict-origin-when-cross-origin sends the full URL for same-origin requests but only the origin (no path or query string) for cross-origin requests. This prevents leaking sensitive URL parameters like tokens or session identifiers.

Permissions-Policy

Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()

Restricts which browser features your application can use. Empty parentheses () disable the feature entirely. This limits the damage if an attacker injects code into your page — even with XSS, they can't activate the camera or access geolocation.

Building a Configurable Middleware

Hardcoding headers works, but a configurable middleware is more maintainable:

SecurityHeadersMiddleware.cs
public class SecurityHeadersMiddleware
{
    private readonly RequestDelegate _next;
    private readonly SecurityHeadersOptions _options;

    public SecurityHeadersMiddleware(
        RequestDelegate next, SecurityHeadersOptions options)
    {
        _next = next;
        _options = options;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        var headers = context.Response.Headers;

        foreach (var (name, value) in _options.Headers)
        {
            headers.Append(name, value);
        }

        foreach (var name in _options.RemoveHeaders)
        {
            headers.Remove(name);
        }

        await _next(context);
    }
}

public class SecurityHeadersOptions
{
    public Dictionary<string, string> Headers { get; } = new();
    public List<string> RemoveHeaders { get; } = new();

    public SecurityHeadersOptions AddHeader(string name, string value)
    {
        Headers[name] = value;
        return this;
    }

    public SecurityHeadersOptions RemoveHeader(string name)
    {
        RemoveHeaders.Add(name);
        return this;
    }
}

Create an extension method for clean registration:

SecurityHeadersExtensions.cs
public static class SecurityHeadersExtensions
{
    public static IApplicationBuilder UseSecurityHeaders(
        this IApplicationBuilder app,
        Action<SecurityHeadersOptions>? configure = null)
    {
        var options = new SecurityHeadersOptions()
            .AddHeader("X-Content-Type-Options", "nosniff")
            .AddHeader("X-Frame-Options", "DENY")
            .AddHeader("Referrer-Policy", "strict-origin-when-cross-origin")
            .AddHeader("X-XSS-Protection", "0")
            .AddHeader("Permissions-Policy",
                "camera=(), microphone=(), geolocation=()");

        options.RemoveHeader("Server");
        options.RemoveHeader("X-Powered-By");

        configure?.Invoke(options);

        return app.UseMiddleware<SecurityHeadersMiddleware>(options);
    }
}

Usage:

Program.cs
app.UseSecurityHeaders(options =>
{
    options.AddHeader("X-Frame-Options", "SAMEORIGIN"); // Override default
    options.AddHeader("Content-Security-Policy", "default-src 'self'");
});

Removing the Server Header

Kestrel adds a Server: Kestrel header by default. While security through obscurity isn't a strategy, there's no reason to advertise your technology stack:

Program.cs
builder.WebHost.ConfigureKestrel(options =>
{
    options.AddServerHeader = false;
});

This is more reliable than removing the header in middleware, as it prevents it from being added in the first place.

Testing Your Headers

After deploying, verify your headers. You can write integration tests:

Example.cs
[Fact]
public async Task Responses_Include_Security_Headers()
{
    await using var factory = new WebApplicationFactory<Program>();
    var client = factory.CreateClient();

    var response = await client.GetAsync("/");

    Assert.Equal("nosniff",
        response.Headers.GetValues("X-Content-Type-Options").First());
    Assert.Equal("DENY",
        response.Headers.GetValues("X-Frame-Options").First());
    Assert.Equal("strict-origin-when-cross-origin",
        response.Headers.GetValues("Referrer-Policy").First());
    Assert.False(response.Headers.Contains("Server"));
}

Online tools like securityheaders.com also provide a quick assessment of your production headers.

Headers to Avoid

X-XSS-Protection: 1; mode=block. This was a well-intentioned IE feature that ironically introduced new vulnerabilities. Modern browsers have removed it. Set it to 0 to disable explicitly, or omit it entirely.

X-Powered-By. Some hosting platforms add this. Remove it — it only helps attackers fingerprint your stack.

Wrapping Up

Security headers take minutes to implement and protect against entire classes of attacks. Start with X-Content-Type-Options, X-Frame-Options, Referrer-Policy, and Permissions-Policy. Add a Content Security Policy when you're ready. Remove identifying headers. Then write tests to make sure they stay in place. It's a small investment with a disproportionately large security payoff.