HTTP Strict Transport Security sounds simple: send one header, force HTTPS, move on.

In production, it’s one of those settings that can either quietly protect your app for years or lock users into a broken setup because you flipped the wrong switch too early.

If you run ASP.NET Core, HSTS is easy to enable. Getting it right takes a bit more care.

What HSTS actually does

HSTS tells the browser:

  • only use HTTPS for this site
  • automatically rewrite future http:// requests to https://
  • refuse insecure certificate bypasses while the policy is active

The header looks like this:

Strict-Transport-Security: max-age=31536000; includeSubDomains

Common directives:

  • max-age=31536000 — browser remembers the rule for 1 year
  • includeSubDomains — apply the rule to all subdomains
  • preload — asks browsers to hardcode your domain into preload lists

HSTS only works after the browser sees the header over HTTPS. If a user’s very first visit is plain HTTP, HSTS hasn’t helped yet. That first-hop gap is why HTTPS redirects still matter, and why preload exists.

The ASP.NET Core default setup

ASP.NET Core has built-in middleware for HSTS and HTTPS redirection.

A typical Program.cs looks like this:

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllersWithViews();

var app = builder.Build();

if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/Home/Error");
    app.UseHsts();
}

app.UseHttpsRedirection();
app.UseStaticFiles();

app.UseRouting();
app.UseAuthorization();

app.MapDefaultControllerRoute();

app.Run();

That app.UseHsts() call adds the Strict-Transport-Security header to HTTPS responses.

And yes, the standard template only enables it outside development. That’s the right default.

Why you should not enable HSTS in development

I’ve seen people turn on HSTS locally, then spend an hour wondering why browsers keep forcing https://localhost even after they changed app settings.

Once a browser caches HSTS for a host, it keeps upgrading requests until the max-age expires or you manually clear browser state.

For local development, that’s just friction.

Stick with:

if (!app.Environment.IsDevelopment())
{
    app.UseHsts();
}

Customizing HSTS in ASP.NET Core

The default middleware settings are fine for a lot of apps, but you should usually configure them explicitly so nobody has to guess what your policy is.

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddHsts(options =>
{
    options.Preload = false;
    options.IncludeSubDomains = true;
    options.MaxAge = TimeSpan.FromDays(365);

    options.ExcludedHosts.Add("healthcheck.internal");
    options.ExcludedHosts.Add("legacy.example.local");
});

builder.Services.AddControllersWithViews();

var app = builder.Build();

if (!app.Environment.IsDevelopment())
{
    app.UseHsts();
}

app.UseHttpsRedirection();

app.MapControllers();

app.Run();

What these settings mean

  • Preload = false
    Don’t rush this. Preload is not a casual toggle.

  • IncludeSubDomains = true
    Good if every subdomain supports HTTPS correctly. Dangerous if even one old subdomain doesn’t.

  • MaxAge = TimeSpan.FromDays(365)
    One year is common for mature deployments.

  • ExcludedHosts
    Lets you skip HSTS for specific hosts handled by the app.

Start small before going strict

My preferred rollout looks like this:

  1. enable HTTPS redirection
  2. deploy HSTS with a short max-age, like 1 day
  3. verify all environments and subdomains behave correctly
  4. increase to 30 days
  5. increase to 6 months or 1 year
  6. only then consider includeSubDomains and preload

Example of a cautious initial policy:

builder.Services.AddHsts(options =>
{
    options.MaxAge = TimeSpan.FromDays(1);
    options.IncludeSubDomains = false;
    options.Preload = false;
});

After you’re confident:

builder.Services.AddHsts(options =>
{
    options.MaxAge = TimeSpan.FromDays(365);
    options.IncludeSubDomains = true;
    options.Preload = false;
});

That gradual rollout saves you from painful surprises.

HSTS depends on correct HTTPS redirection

HSTS is not a substitute for redirecting HTTP to HTTPS. You want both.

In ASP.NET Core:

app.UseHttpsRedirection();

If your app is behind a reverse proxy or load balancer, make sure ASP.NET Core correctly understands the original request scheme. Otherwise, your app might think every request is HTTP and generate bad redirects or fail to apply security logic correctly.

Typical proxy-aware setup:

using Microsoft.AspNetCore.HttpOverrides;

var builder = WebApplication.CreateBuilder(args);

builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
    options.ForwardedHeaders =
        ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;

    // Add known proxies/networks in real deployments
    // options.KnownProxies.Add(IPAddress.Parse("10.0.0.100"));
});

builder.Services.AddHsts(options =>
{
    options.MaxAge = TimeSpan.FromDays(365);
    options.IncludeSubDomains = true;
});

var app = builder.Build();

app.UseForwardedHeaders();

if (!app.Environment.IsDevelopment())
{
    app.UseHsts();
}

app.UseHttpsRedirection();

app.MapGet("/", () => "Hello over HTTPS");

app.Run();

If you’re terminating TLS at a proxy and forwarding plain HTTP to Kestrel, this part is non-negotiable.

Middleware order matters

Put HSTS early enough in the pipeline that it applies to your HTTPS responses.

A safe pattern is:

if (!app.Environment.IsDevelopment())
{
    app.UseHsts();
}

app.UseHttpsRedirection();

That’s the common production layout.

A subtle point: browsers only honor HSTS headers received over HTTPS. Sending the header on an HTTP response is useless.

When includeSubDomains is a bad idea

People love copying this:

Strict-Transport-Security: max-age=31536000; includeSubDomains; preload

That’s fine only if you fully control every subdomain and every one of them has valid HTTPS.

Real-world reasons to avoid includeSubDomains for now:

  • old admin tools on obscure subdomains
  • forgotten marketing microsites
  • internal apps exposed through split DNS
  • mail-related subdomains with weird legacy setups
  • temporary environments like dev.example.com or test.example.com

If one subdomain breaks under HTTPS, users may be locked out of it.

Be boring and audit first.

Preload is permanent enough to be scary

preload tells browser vendors you want your domain baked into HSTS preload lists.

In ASP.NET Core, that’s just:

builder.Services.AddHsts(options =>
{
    options.MaxAge = TimeSpan.FromDays(365);
    options.IncludeSubDomains = true;
    options.Preload = true;
});

But the operational impact is much bigger than the code suggests.

Before enabling preload, make sure:

  • your apex domain is always HTTPS
  • www and non-www are consistently covered
  • all subdomains support HTTPS
  • you actually want includeSubDomains
  • you can keep this true long-term

I treat preload like a one-way door. Not truly irreversible, but slow and annoying to unwind.

Verifying the header

You can check with browser dev tools, curl, or a header scanner.

Using curl:

curl -I https://example.com

Expected output:

HTTP/1.1 200 OK
Strict-Transport-Security: max-age=31536000; includeSubDomains

If you want a quick scan of your response headers, you can use Headertest.

For framework behavior and options, the official ASP.NET Core docs are here: https://learn.microsoft.com/aspnet/core/security/enforcing-ssl

A production-ready example

Here’s a more realistic Program.cs for an MVC app behind a proxy:

using Microsoft.AspNetCore.HttpOverrides;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllersWithViews();

builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
    options.ForwardedHeaders =
        ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
});

builder.Services.AddHsts(options =>
{
    options.Preload = false;
    options.IncludeSubDomains = true;
    options.MaxAge = TimeSpan.FromDays(180);
});

var app = builder.Build();

app.UseForwardedHeaders();

if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/Home/Error");
    app.UseHsts();
}

app.UseHttpsRedirection();
app.UseStaticFiles();

app.UseRouting();
app.UseAuthorization();

app.MapControllerRoute(
    name: "default",
    pattern: "{controller=Home}/{action=Index}/{id?}");

app.Run();

That’s a sane baseline for most production apps.

Common mistakes I keep seeing

1. Enabling HSTS before HTTPS is fully working

If certificate renewal, proxy config, or redirects are flaky, fix that first.

2. Turning on includeSubDomains without an inventory

If you don’t know every subdomain you own, you’re not ready.

3. Using a huge max-age on day one

Start short. Increase later.

4. Enabling HSTS in local development

You’ll just annoy yourself and your team.

5. Forgetting reverse proxy headers

This breaks more ASP.NET Core deployments than people admit.

Good defaults for most ASP.NET Core apps

If you want my opinionated baseline:

  • use UseHttpsRedirection()
  • enable HSTS only outside development
  • start with 30 to 180 days
  • avoid preload until you’ve lived with HSTS in production
  • use includeSubDomains only after auditing subdomains

Example:

builder.Services.AddHsts(options =>
{
    options.MaxAge = TimeSpan.FromDays(90);
    options.IncludeSubDomains = false;
    options.Preload = false;
});

Later, once you’re sure:

builder.Services.AddHsts(options =>
{
    options.MaxAge = TimeSpan.FromDays(365);
    options.IncludeSubDomains = true;
    options.Preload = false;
});

HSTS is one of the highest-value low-effort headers you can add to an ASP.NET Core app. The trick is treating it like a deployment policy, not just a middleware checkbox.