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.HttpsPort in code,
  • the https_port setting: ASPNETCORE_HTTPS_PORT environment variable or a top-level "https_port" in appsettings.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 KnownNetworks with new IPNetwork(IPAddress.Parse("10.0.0.0"), 8). On .NET 10 use KnownIPNetworks, which takes System.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; and using Microsoft.AspNetCore.HttpOverrides;. Write System.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

SymptomCauseFix
Log: Failed to determine the https port for redirect, page served over HTTPApp has no HTTPS endpoint and no https_portASPNETCORE_HTTPS_PORT=443 (behind a proxy) or add an HTTPS endpoint
Redirect goes to https://localhost/ without the portHttpsPort = 443 hard-codedSet the port in config per environment
No Strict-Transport-Security headerTesting on localhost, Development env, or HTTP requestTest with a real host name over HTTPS in Production
ERR_TOO_MANY_REDIRECTS behind Nginx/ingress/load balancerApp sees HTTP; forwarded headers ignoredUseForwardedHeaders() + proxy’s network in KnownIPNetworks
Works locally, loops in Docker/KubernetesOnly loopback proxies are trusted by defaultAdd the container/VNet CIDR to KnownIPNetworks
ASPDEPR005 on KnownNetworksRenamed in .NET 10Use KnownIPNetworks with System.Net.IPNetwork
CS0104 ambiguous IPNetworkTwo IPNetwork types importedWrite System.Net.IPNetwork in full
API clients get 307 and resend dataRedirect used for an APIReturn 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).

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *