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 tohttps:// - 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 yearincludeSubDomains— apply the rule to all subdomainspreload— 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:
- enable HTTPS redirection
- deploy HSTS with a short
max-age, like 1 day - verify all environments and subdomains behave correctly
- increase to 30 days
- increase to 6 months or 1 year
- only then consider
includeSubDomainsandpreload
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.comortest.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
wwwand non-wwware 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
preloaduntil you’ve lived with HSTS in production - use
includeSubDomainsonly 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.