Skip to content
ExponaBLOG
.NET & ASP.NET CORE · DAY 080 / 100

Security headers and HTTPS: .NET lessons from production

Small middleware, big protection.

Expona Admin7 min read

On day 80 of this .NET journey, we shift focus from application logic to something equally critical: hardening your ASP.NET Core API against the browser's worst assumptions. Security headers and HTTPS form the invisible wall between your users and the attackers who'd love to intercept, modify, or inject malicious content into their sessions. A few middleware lines and some Kestrel configuration can stop entire classes of attacks before they reach your business logic.

Why security headers matter before day 80

By now, you've built authentication, validated input, and maybe even added rate limiting. But here's the uncomfortable truth: the browser itself is a vector. If an attacker finds an XSS flaw or tricks a user into visiting a malicious link on your domain, the browser has no way to know you didn't intend for a third-party script to run in your user's session. Security headers are your voice in the browser's ear, telling it to follow stricter rules than it would by default. They're not a replacement for secure coding—they're a safety net when your code isn't perfect.

HTTPS ensures the transport layer is encrypted. Security headers ensure the browser enforces your security policies. Together, they're the foundation that everything else rests on.

HSTS — The permanent HTTPS lock

HTTP Strict-Transport-Security (HSTS) is elegantly simple: it tells the browser "For the next N seconds, only connect to me over HTTPS. If someone tries HTTP, refuse it." The browser stores this instruction and enforces it even if the user types the domain without the protocol.

I learned this the hard way during a payment processing integration. One typo in a config file directed users to http://api.service.local instead of https://. Without HSTS, older browsers would silently downgrade. With it, the browser blocked the connection and surfaced an error immediately. That friction caught the bug in testing.

Here's how to enable it in ASP.NET Core:

csharp
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

var isDevelopment = app.Environment.IsDevelopment();

if (!isDevelopment)
{
    app.UseHsts();
}

app.UseHttpsRedirection();
app.Run();

Critical: Only enable HSTS in production. The Strict-Transport-Security header, when sent over HTTPS, tells the browser to pin HTTPS for a long time. Sending it in development over localhost can lock you out if you're testing HTTP locally.

The default HSTS middleware sets max-age to 30 days. For production, consider increasing it to 1 year once you're confident:

csharp
app.UseHsts(options =>
{
    options.MaxAge(TimeSpan.FromDays(365));
    options.IncludeSubDomains();
    options.Preload();
});

The Preload directive lets you submit your domain to the HSTS preload list, baking your domain into the browser source code. High confidence move, but commit to HTTPS first.

Content-Security-Policy — The XSS fortress

Content-Security-Policy (CSP) is the strongest XSS defense you can layer on without rewriting your DOM architecture. It works by whitelisting exactly where scripts, styles, images, and other resources can load from. If an attacker injects <script src="evil.com/steal.js"></script>, the browser rejects it because evil.com isn't on your whitelist.

I've seen CSP catch injections that slipped past input validation. A user uploaded an SVG that contained an embedded script tag. The DOM parser rendered it fine; CSP killed the script before execution. That's defense in depth.

A basic CSP for a SPA that loads its own scripts looks like this:

csharp
app.Use(async (context, next) =>
{
    var cspHeader = "default-src 'self'; " +
                    "script-src 'self' 'unsafe-inline' cdn.example.com; " +
                    "style-src 'self' 'unsafe-inline'; " +
                    "img-src 'self' data: https:; " +
                    "font-src 'self'; " +
                    "connect-src 'self' api.example.com; " +
                    "frame-ancestors 'none'; " +
                    "base-uri 'self'; " +
                    "form-action 'self'";

    context.Response.Headers.Add("Content-Security-Policy", cspHeader);
    await next();
});

Let me break the directives:

  • default-src 'self' — Everything defaults to same-origin only. Whitelist specific resource types if you need exceptions.
  • script-src — Where scripts execute from. Avoid 'unsafe-inline' in production; use nonces or hashes instead.
  • style-src — Stylesheet sources. Same advice on 'unsafe-inline'.
  • img-src — Image sources. Typically relaxed because images rarely execute code, but CDN images still need whitelisting.
  • connect-src — Fetch, XMLHttpRequest, WebSocket destinations. Critical for APIs.
  • frame-ancestors 'none' — Prevents your page from being embedded in iframes. Stops clickjacking.
  • form-action 'self' — Only your own server can be a form target.

CSP is strict. During rollout, use the report-only mode first:

csharp
context.Response.Headers.Add("Content-Security-Policy-Report-Only", cspHeader);

Browsers will log violations to the console and to a report endpoint without blocking content. Once you've tuned the policy, switch to the enforcement header.

Other essential headers — The defensive lineup

CSP and HSTS are the heavy hitters, but the supporting cast matters too.

X-Content-Type-Options: nosniff

Tells the browser: don't guess the MIME type. If you say a file is text/plain, treat it as text, not as script. Prevents a whole class of polyglot attack vectors.

Referrer-Policy

Controls what information leaks in the Referer header when users click links. strict-origin means only send the origin, not the full URL path. Useful for hiding internal structure from analytics and third parties.

X-Frame-Options (superseded by CSP frame-ancestors)

Legacy header for preventing clickjacking. Modern browsers prefer CSP, but it's cheap to include both for older clients.

Here's a complete middleware that stacks all of them:

csharp
app.Use(async (context, next) =>
{
    var headers = context.Response.Headers;
    
    // Enforce HTTPS and disable MIME sniffing
    headers["X-Content-Type-Options"] = "nosniff";
    headers["X-Frame-Options"] = "DENY";
    
    // Control referrer information leakage
    headers["Referrer-Policy"] = "strict-origin";
    
    // Disable XSS auditor if present (can cause false positives)
    headers["X-XSS-Protection"] = "0";
    
    // Restrict permissions for sensors, camera, etc.
    headers["Permissions-Policy"] = "geolocation=(), microphone=(), camera=()";
    
    await next();
});

Place this middleware early in your pipeline, right after HTTPS redirection and before routing.

Remove the Server header — Don't advertise your stack

By default, Kestrel announces itself in the Server response header: "Microsoft-IIS/10.0" or "Kestrel/7.0". This is free reconnaissance for attackers. One fewer breadcrumb to follow.

Disable it in your Program.cs when configuring Kestrel:

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

Tiny change, zero performance cost, no legitimate reason to advertise your tech stack to the internet.

Putting it together — A real middleware module

Here's how I organize security headers in production services:

csharp
public static class SecurityHeaderMiddleware
{
    public static WebApplication UseSecurityHeaders(
        this WebApplication app,
        bool isProduction)
    {
        app.Use(async (context, next) =>
        {
            var headers = context.Response.Headers;

            // Always apply
            headers["X-Content-Type-Options"] = "nosniff";
            headers["X-Frame-Options"] = "DENY";
            headers["Referrer-Policy"] = "strict-origin";
            headers["X-XSS-Protection"] = "0";
            headers["Permissions-Policy"] = 
                "geolocation=(), microphone=(), camera=()";

            // Production-only
            if (isProduction)
            {
                var csp = "default-src 'self'; " +
                          "script-src 'self' cdn.example.com; " +
                          "style-src 'self' cdn.example.com; " +
                          "img-src 'self' data: https:; " +
                          "connect-src 'self' api.example.com; " +
                          "frame-ancestors 'none'; " +
                          "base-uri 'self'; " +
                          "form-action 'self'";
                
                headers["Content-Security-Policy"] = csp;
            }

            await next();
        });

        return app;
    }
}

// In Program.cs
if (!app.Environment.IsDevelopment())
{
    app.UseHsts();
}

app.UseHttpsRedirection();
app.UseSecurityHeaders(app.Environment.IsProduction());
app.MapControllers();

Common mistakes

  • Enabling HSTS in development. You'll regret it when you need to test HTTP locally. Use environment checks.
  • Using 'unsafe-inline' in CSP everywhere. It defeats half the purpose of CSP. Use nonces (random tokens per response) or script hashes instead. Yes, it's more work. Yes, it's worth it.
  • Setting CSP too permissive, then leaving it. Start strict (report-only), measure violations, relax only what's necessary. Audit quarterly.
  • Forgetting to whitelist your API domain in connect-src. Your AJAX calls will silently fail. Test thoroughly.
  • Not testing in report-only first. Jump straight to enforcement and your real users become your QA team. Not fun.
  • Ignoring the Server header. It's metadata debt. Remove it and sleep better.

Key takeaways

  • HSTS forces HTTPS enforcement at the browser level. Enable only in production to avoid locking yourself out during development.
  • CSP is the strongest XSS defense available. Start in report-only mode, measure violations, then enforce. Avoid 'unsafe-inline'.
  • X-Content-Type-Options: nosniff prevents MIME type sniffing attacks. Pair it with frame-ancestors via CSP to block clickjacking.
  • Disable the Server header in Kestrel configuration to avoid advertising your tech stack.
  • Security headers are cheap insurance. A few middleware lines and Kestrel config changes stop entire attack vectors before they reach your code.
  • Environment-aware configuration is essential. Different rules for dev, staging, and production protect your team without friction.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles