Enforce HTTPS in ASP.NET Core: Redirects, HSTS and Proxies
Last updated: October 12, 2026 · Tested with .NET 10 (SDK 10.0.401, ASP.NET Core 10.0.12), Kestrel, curl 8, Windows 11
Short answer: to enforce HTTPS in ASP.NET Core, call app.UseHttpsRedirection() and, outside Development, app.UseHsts(). The new-project template already does both. Most problems come from what happens around them. If the app has no HTTPS port, the middleware logs Failed to determine the https port for redirect and serves plain HTTP. HSTS is never sent to localhost. Behind a reverse proxy you get an endless redirect loop unless the forwarded headers middleware trusts the proxy’s IP, and by default it trusts only loopback. Every example below ran on .NET 10. The responses are copied from curl and the log lines from the console.
The Default Setup: UseHttpsRedirection and UseHsts
This is the part of Program.cs that matters, as in the dotnet new web / webapp templates:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHsts(options =>
{
options.MaxAge = TimeSpan.FromDays(365);
options.IncludeSubDomains = true;
options.Preload = true;
});
var app = builder.Build();
if (!app.Environment.IsDevelopment())
{
app.UseHsts();
}
app.UseHttpsRedirection();
app.MapGet("/", (HttpContext ctx) => $"scheme={ctx.Request.Scheme} host={ctx.Request.Host}");
app.Run();
Run it in Production with both an HTTP and an HTTPS endpoint (the dev certificate is enough for a local test) and call the HTTP one:
set ASPNETCORE_ENVIRONMENT=Production
set ASPNETCORE_URLS=http://localhost:5080;https://localhost:5443
dotnet run --no-launch-profile
curl -si http://localhost:5080/
HTTP/1.1 307 Temporary Redirect
Location: https://localhost:5443/
The default is a 307, not a 301 or 308, and the middleware picked up port 5443 from the server addresses on its own. Keep UseHttpsRedirection() early in the pipeline, before static files, routing and auth, so nothing is served over HTTP first.
Failed to Determine the HTTPS Port for Redirect
Run the same app with only an HTTP endpoint, which is what you get in a container that listens on 8080, or with the http launch profile:
set ASPNETCORE_URLS=http://localhost:5080
dotnet run --no-launch-profile
curl -si http://localhost:5080/
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
Server: Kestrel
warn: Microsoft.AspNetCore.HttpsPolicy.HttpsRedirectionMiddleware[3]
Failed to determine the https port for redirect.
No exception and no redirect. The request is served over HTTP with a 200, and the only sign is one warning in the log. The middleware needs to know which port to redirect to. It takes it from, in practice:
HttpsRedirectionOptions.HttpsPortin code,- the
https_portsetting:ASPNETCORE_HTTPS_PORTenvironment variable or a top-level"https_port"inappsettings.json, - an
https://address the server itself listens on (ASPNETCORE_URLS, Kestrel endpoints,launchSettings.json).
When TLS ends at a proxy or load balancer, the app has no HTTPS endpoint of its own, so set the port explicitly with ASPNETCORE_HTTPS_PORT=443.
307 vs 308, and the HttpsPort = 443 Trap
A permanent redirect is the right choice in production: browsers and search engines cache it and treat the HTTPS URL as the real one. You configure it in AddHttpsRedirection:
builder.Services.AddHttpsRedirection(options =>
{
options.RedirectStatusCode = StatusCodes.Status308PermanentRedirect;
options.HttpsPort = 443;
});
curl -si "http://localhost:5080/a?x=1"
HTTP/1.1 308 Permanent Redirect
Location: https://localhost/a?x=1
The 308 works and the path and query string are kept. But look at the host. With HttpsPort = 443 hard-coded, the port is gone, and locally (where HTTPS is on 5443) the redirect goes nowhere. Many tutorials, including the earlier version of this one, put HttpsPort = 443 straight into code. Set the status code in code and leave the port to configuration: ASPNETCORE_HTTPS_PORT=443 on the server, nothing locally. 308 (not 301) also tells clients to repeat a POST as a POST.
UseHsts Not Adding the Strict-Transport-Security Header
The most common “HSTS doesn’t work” report is a test against localhost. Same app, Production, three HTTPS requests:
curl -sik https://localhost:5443/
curl -sik https://127.0.0.1:5443/
curl -sik -H "Host: shop.example.com" https://localhost:5443/
HTTP/1.1 200 OK
scheme=https host=localhost:5443
HTTP/1.1 200 OK
HTTP/1.1 200 OK
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
scheme=https host=shop.example.com
UseHsts() skips localhost, 127.0.0.1 and [::1] on purpose (they are in HstsOptions.ExcludedHosts), so a browser doesn’t stay locked to HTTPS for every local app you run. Send a different Host header, as above, to see the real header. The other reasons the header is missing:
- The request is HTTP. HSTS is only sent on HTTPS responses; browsers ignore it over HTTP.
- The environment is Development, and the template wraps
UseHsts()in!IsDevelopment(). - TLS ends at a proxy and the app thinks the request is HTTP (next section). Fix the scheme and the header appears.
Without AddHsts, max-age defaults to 30 days. For the browser preload list you need at least one year (31536000), includeSubDomains and preload, and every subdomain must serve HTTPS. Start with a short MaxAge. A browser that has cached a one-year policy will refuse HTTP for that long, even if you remove the header.
Infinite Redirect Loop Behind a Reverse Proxy (ERR_TOO_MANY_REDIRECTS)
Behind Nginx, YARP, Traefik, a Kubernetes ingress or a cloud load balancer, the client talks HTTPS to the proxy and the proxy talks HTTP to the app. The app sees HTTP and redirects to HTTPS. That request reaches the proxy, which forwards it as HTTP again, so the browser keeps getting redirected. To reproduce it, the app below listens on HTTP only with ASPNETCORE_HTTPS_PORT=443, and curl plays the proxy by sending X-Forwarded-Proto: https:
curl -si -H "X-Forwarded-Proto: https" http://localhost:5080/
HTTP/1.1 307 Temporary Redirect
Location: https://localhost/
The standard fix is the forwarded headers middleware, before UseHttpsRedirection:
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
});
var app = builder.Build();
app.UseForwardedHeaders();
curl -si -H "X-Forwarded-Proto: https" http://localhost:5080/
HTTP/1.1 200 OK
scheme=https host=localhost:5080
curl -si -H "X-Forwarded-Proto: https" http://192.168.1.6:5080/
HTTP/1.1 307 Temporary Redirect
Location: https://192.168.1.6/
Same app, same header, two results. From localhost it works. From the machine’s LAN address it still redirects. By default the middleware only trusts proxies on loopback and silently ignores X-Forwarded-* from any other IP. That is why the fix works on your machine and loops in Docker or Kubernetes, where the proxy comes from 172.x or 10.x. Add the proxy’s network:
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
options.KnownIPNetworks.Add(System.Net.IPNetwork.Parse("192.168.1.0/24"));
});
curl -si -H "X-Forwarded-Proto: https" http://192.168.1.6:5080/
HTTP/1.1 200 OK
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
scheme=https host=192.168.1.6:5080
Now the scheme is https, the loop is gone and the HSTS header shows up as well. Two things I hit on .NET 10:
- warning ASPDEPR005: ‘ForwardedHeadersOptions.KnownNetworks’ is obsolete: ‘Please use KnownIPNetworks instead.’ Most answers online still use
KnownNetworkswithnew IPNetwork(IPAddress.Parse("10.0.0.0"), 8). On .NET 10 useKnownIPNetworks, which takesSystem.Net.IPNetwork. - error CS0104: ‘IPNetwork’ is an ambiguous reference between ‘Microsoft.AspNetCore.HttpOverrides.IPNetwork’ and ‘System.Net.IPNetwork’ when the file has both
using System.Net;andusing Microsoft.AspNetCore.HttpOverrides;. WriteSystem.Net.IPNetwork.Parse(...)in full.
The shortcut is the environment variable ASPNETCORE_FORWARDEDHEADERS_ENABLED=true. With it, and no code change, the request from 192.168.1.6 returned 200 with scheme=https. It clears the known proxies and networks, so it accepts X-Forwarded-* from any IP. Microsoft’s proxy guide warns about exactly that. Use it only when nothing but the proxy can reach the app, for example a container that is reachable only through the ingress. Otherwise a client can send X-Forwarded-For and fake its IP in your logs and rate limits.
Full Program.cs for Production Behind a Proxy
using Microsoft.AspNetCore.HttpOverrides;
var builder = WebApplication.CreateBuilder(args);
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
// The proxy's network, e.g. the Docker bridge or your VNet subnet
options.KnownIPNetworks.Add(System.Net.IPNetwork.Parse(builder.Configuration["ProxyNetwork"] ?? "10.0.0.0/8"));
});
builder.Services.AddHsts(options =>
{
options.MaxAge = TimeSpan.FromDays(365);
options.IncludeSubDomains = true;
options.Preload = true;
});
var app = builder.Build();
app.UseForwardedHeaders();
if (!app.Environment.IsDevelopment())
{
app.UseHsts();
}
app.UseHttpsRedirection();
app.MapGet("/", (HttpContext ctx) => $"scheme={ctx.Request.Scheme} remote={ctx.Connection.RemoteIpAddress}");
app.Run();
Run with ProxyNetwork=192.168.1.0/24 and https_port=443 (Production, HTTP only), first a direct HTTP request, then one that looks like it came through the proxy:
curl -si http://192.168.1.6:5080/login
HTTP/1.1 307 Temporary Redirect
Location: https://192.168.1.6/login
curl -si -H "X-Forwarded-Proto: https" -H "X-Forwarded-For: 203.0.113.7" -H "Host: shop.example.com" http://192.168.1.6:5080/
HTTP/1.1 200 OK
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
scheme=https remote=203.0.113.7
Plain HTTP is redirected. Proxied HTTPS is served with HSTS, and RemoteIpAddress is the real client IP from X-Forwarded-For instead of the proxy’s.
Web APIs: Return 400 Instead of Redirecting
For APIs, Microsoft’s guidance is the opposite of the browser case. Don’t listen on HTTP at all, or reject HTTP with 400. API clients often don’t follow redirects. Even when they do, the token or body was already sent in clear text on the first request, and the redirect can’t take that back. A small middleware does it:
app.Use(async (ctx, next) =>
{
if (!ctx.Request.IsHttps)
{
ctx.Response.StatusCode = StatusCodes.Status400BadRequest;
await ctx.Response.WriteAsync("HTTPS required");
return;
}
await next();
});
curl -si -X POST -d "card=4111" http://localhost:5080/
HTTP/1.1 400 Bad Request
HTTPS required
curl -sik https://localhost:5443/
HTTP/1.1 200 OK
Put it after UseForwardedHeaders() if a proxy terminates TLS, or IsHttps will be false for every request. Don’t use [RequireHttps] on APIs; it redirects too.
Certificates: Dev Cert, Kestrel and Production
Locally, Kestrel uses the .NET development certificate. Check it and trust it with:
dotnet dev-certs https --check
dotnet dev-certs https --trust
A valid certificate was found: 1B6C502A549396C26276E83E5A936EAA66ABC8A6 - CN=localhost - Valid from 2026-03-28 06:50:19Z to 2027-03-28 06:50:19Z - IsHttpsDevelopmentCertificate: true - IsExportable: true
Run the command with both --check and --trust options to ensure that the certificate is not only valid but also trusted.
If the browser still says the certificate isn’t trusted, dotnet dev-certs https --clean followed by --trust and a browser restart is Microsoft’s documented fix. The dev cert lasts a year; when it expires, HTTPS endpoints stop working until you recreate it.
When Kestrel serves HTTPS itself in production, point it at a real certificate with configuration, not code. A wrong path fails at startup, and a relative path is resolved against the content root, not the folder you ran the command from:
set ASPNETCORE_URLS=https://localhost:5443
set ASPNETCORE_Kestrel__Certificates__Default__Path=missing.pfx
dotnet HttpsDemo.dll
fail: Microsoft.Extensions.Hosting.Internal.Host[11]
Hosting failed to start
System.IO.FileNotFoundException: Could not find file 'D:\...\https-enforce\app\missing.pfx'.
Keep the certificate password in an environment variable or a secret store (Kestrel__Certificates__Default__Password), never in appsettings.json. In most deployments (App Service, a cloud load balancer, an ingress with cert-manager / Let’s Encrypt) the proxy holds the certificate and the app needs only the forwarded headers setup above.
Secure Cookies
HTTPS on the connection doesn’t stop a cookie from going out over HTTP later. Mark session cookies Secure:
app.MapGet("/login", (HttpContext ctx) =>
{
ctx.Response.Cookies.Append("session", "abc123", new CookieOptions
{
HttpOnly = true,
Secure = true,
SameSite = SameSiteMode.Lax,
});
return "cookie set";
});
Set-Cookie: session=abc123; path=/; secure; samesite=lax; httponly
The browser will now only send it over HTTPS. The ASP.NET Core authentication cookie and antiforgery cookie can be set the same way through their own options (Cookie.SecurePolicy = CookieSecurePolicy.Always).
Symptom → Cause → Fix
| Symptom | Cause | Fix |
|---|---|---|
| Log: Failed to determine the https port for redirect, page served over HTTP | App has no HTTPS endpoint and no https_port | ASPNETCORE_HTTPS_PORT=443 (behind a proxy) or add an HTTPS endpoint |
Redirect goes to https://localhost/ without the port | HttpsPort = 443 hard-coded | Set the port in config per environment |
No Strict-Transport-Security header | Testing on localhost, Development env, or HTTP request | Test with a real host name over HTTPS in Production |
| ERR_TOO_MANY_REDIRECTS behind Nginx/ingress/load balancer | App sees HTTP; forwarded headers ignored | UseForwardedHeaders() + proxy’s network in KnownIPNetworks |
| Works locally, loops in Docker/Kubernetes | Only loopback proxies are trusted by default | Add the container/VNet CIDR to KnownIPNetworks |
ASPDEPR005 on KnownNetworks | Renamed in .NET 10 | Use KnownIPNetworks with System.Net.IPNetwork |
CS0104 ambiguous IPNetwork | Two IPNetwork types imported | Write System.Net.IPNetwork in full |
| API clients get 307 and resend data | Redirect used for an API | Return 400 for HTTP, or don’t listen on HTTP |
FAQ
Should I use 301 or 308 for HTTP to HTTPS? 308. Both are permanent, but 308 keeps the method and body, so a POST stays a POST. ASP.NET Core’s default is 307 (temporary), which is fine while you test.
Do I need UseHttpsRedirection if the load balancer already redirects? Not for browsers, if every request passes through it. Keeping it costs nothing once forwarded headers are set up, and it protects you if the app is ever reached directly.
Why does Chrome still go to HTTPS after I removed HSTS? The browser cached the policy for max-age. In Chrome you can delete it for one domain at chrome://net-internals/#hsts; real users keep it until it expires.
Related: ASP.NET Core security best practices · Dockerize an ASP.NET Core app · ASP.NET Core rate limiting (needs the real client IP)
References: Enforce HTTPS in ASP.NET Core (Microsoft Learn) · Configure ASP.NET Core to work with proxy servers and load balancers · HSTS preload list requirements
This article is part of the ASP.NET Core tutorials guide (APIs, security, observability and deployment).