Book a call

Field notes ยท lab reproduction

Who Said It Was HTTPS?

A servicing update decided your load balancer is a stranger. Your app now refuses its word about HTTPS, and on your own machine you will never see it happen.

In our lab, a client playing a browser behind a load balancer requested https://shop.example.test/whoami. The app redirected it to https://shop.example.test/whoami, the same address. We followed the redirect ten times and got the same response ten times. The account page and the sign-in endpoint redirected too, before any sign-in code ran. Nobody could sign in.

The same code had worked in the previous run. The only difference was whether the app trusted the load balancer in front of it. A load balancer that terminates TLS forwards each request over plain HTTP and adds X-Forwarded-Proto: https. Starting with the servicing releases ASP.NET Core 8.0.17 and 9.0.6, the app ignores that header unless it comes from a proxy explicitly configured as trusted. .NET 10 does the same.

We tested one app on .NET 8.0.33, 9.0.22 and 10.0.14 in 16 configurations, each twice. Here is what changed, why your machine hides it, what each documented opt-out reopens, and the fix that keeps the protection.

What the patch changed

Our lab app forwards only the scheme, then redirects HTTP to HTTPS. In Program.cs, after var builder = WebApplication.CreateBuilder(args);, with using Microsoft.AspNetCore.HttpOverrides; at the top:

builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
    options.ForwardedHeaders = ForwardedHeaders.XForwardedProto;
});
builder.Services.AddHttpsRedirection(options => options.HttpsPort = 443);

var app = builder.Build();
app.UseForwardedHeaders();
app.UseHttpsRedirection();

The forwarded headers middleware has two lists of trusted sources, KnownProxies and KnownNetworks. By default, they contain only loopback addresses: ::1 and 127.0.0.0/8. Up to 8.0.16 and 9.0.5, the middleware checked those lists only when configured to process X-Forwarded-For. If an app forwarded just the scheme, it accepted X-Forwarded-Proto from anyone.

Microsoft's breaking-change note explains why this changed: "the proxy and IP lists weren't being applied in all cases". The middleware now checks them in every case. If a request comes from an address outside the lists, it ignores all of that request's X-Forwarded-* headers. That includes X-Forwarded-Host and X-Forwarded-Prefix, which fall under the same change; we measured only the scheme. The app then sees the request as it arrived: plain HTTP. The note also describes the consequence: "infinite redirects if you're using the HTTPS redirection middleware and using TLS termination in your proxy".

Apps that also forward X-Forwarded-For were already subject to these checks, so the patch did not change their behavior. In our lab, an app forwarding both headers looped behind an untrusted load balancer on all three runtimes, with or without the switch that restores the old behavior. These apps needed to list the load balancer as trusted to work even before the patch. The patch caught apps that had been working because they accepted the scheme from any source.

Why it works on your machine

If you test over HTTPS without a proxy, the scheme is real and no header is involved. If a local proxy connects to the app over loopback, the default lists trust it. In our lab, we kept the lists as shipped and ran the stand-in load balancer on loopback. Every request worked on all three runtimes.

In production, the load balancer connects from its own address, such as 10.0.0.4, which isn't in the default lists. Since our lab uses loopback, we simulated this by removing 127.0.0.0/8 from the trusted networks. The app then treated our stand-in load balancer at 127.0.0.2 as an unknown proxy.

What the app sees

The loop hides everything else, so we turned HTTPS redirection off to see how the app read the request. The results were the same on all three runtimes:

What the app producedLoad balancer trustedLoad balancer unknown
Request.Schemehttpshttp
Link built with LinkGeneratorhttpshttp
Redirect to the login pagehttpshttp
Secure on the authentication cookieyesno

What the app produced behind the unknown load balancer, as recorded:

link:     http://shop.example.test/receipts/42
redirect: http://shop.example.test/Account/Login?ReturnUrl=%2Faccount
cookie:   .AspNetCore.Cookies=<ticket>; path=/; samesite=lax; httponly

The cookie authentication handler uses Request.Scheme and Request.Host to build the login redirect. By default, it also marks the cookie Secure only when the request is HTTPS. The OpenID Connect handler uses the same helper to build its redirect_uri, so the identity provider receives an http callback address. Microsoft's proxy guide puts it plainly: "OAuth and OIDC also fail in this configuration because they generate incorrect redirects." We did not test OpenID Connect because it ships as a NuGet package, and our lab uses only the shared framework.

With HTTPS redirection on, the app never gets that far.

The redirect loop and the opt-outs

These tests use the same app, with scheme forwarding and HTTPS redirection enabled. Each row changes one thing:

Configuration.NET 8.0.33.NET 9.0.22.NET 10.0.14
Lists as shipped, proxy on loopbackworksworksworks
Load balancer not in the listslooplooploop
AppContext switch IgnoreUnknownProxiesWithoutForworksworksloop
Its environment variable, ..._IGNORE_UNKNOWN_PROXIES_WITHOUT_FOR=trueworksworksloop
ASPNETCORE_FORWARDEDHEADERS_ENABLED=trueworksworksworks
Both lists cleared in codeworksworksworks
Load balancer's address in KnownProxiesworksworksworks

"loop" means ten 307 redirects to the address the browser originally requested. "works" means HTTP 200, an https link and a Secure cookie.

The switch and its environment variable restore the old behavior on .NET 8 and 9. The documentation limits them to .NET 9 or earlier, and neither had any effect on 10.0.14. The code that reads them is absent from the 10.0 middleware.

Both options arrived after the change itself: they appear in the source from 8.0.19 and 9.0.8. On 8.0.17, 8.0.18, 9.0.6 and 9.0.7, the two documented ways to restore the old behavior both trust everyone.

What the opt-outs reopen

The old behavior is the hole the patch closed. We tested it with a second client that connected directly to the app from 127.0.0.3, bypassing the load balancer. The client supplied its own headers: X-Forwarded-Proto: https and X-Forwarded-For: 198.51.100.23.

ConfigurationScheme the app sawClient address the app saw
Load balancer's address in KnownProxiesheaders ignored, redirected to HTTPSendpoint not reached
Compatibility switch, .NET 8 and 9https127.0.0.3
ASPNETCORE_FORWARDEDHEADERS_ENABLED=truehttps127.0.0.3
Lists cleared, scheme onlyhttps127.0.0.3
Lists cleared, X-Forwarded-For forwarded toohttps198.51.100.23

With the lists cleared and X-Forwarded-For processed, the app recorded the address the caller had made up. IP allowlists, per-client rate limits, audit logs and the Secure flag on cookies then follow whatever the caller wrote. The environment variable also enables X-Forwarded-For; our app sets ForwardedHeaders itself, which kept the client address real in that row.

Microsoft notes that proxy addresses in cloud environments can change over the life of an app, and that ASPNETCORE_FORWARDEDHEADERS_ENABLED is sometimes used for this reason. Trusting every source is safe only if nothing but the load balancer can reach the app and the load balancer replaces these headers instead of passing through what the client sent. When the address changes, trust a subnet that holds only your load balancers.

What the logs show

At the ASP.NET Core template's default logging levels, the app logged nothing while handling our requests in any scenario. The middleware reports an unknown proxy at Debug level, but the template sets Microsoft.AspNetCore to Warning. After we raised two categories to Debug, every request in the loop produced this pair (port shortened):

dbug: Microsoft.AspNetCore.HttpOverrides.ForwardedHeadersMiddleware[1] Unknown proxy: 127.0.0.2:...
dbug: Microsoft.AspNetCore.HttpsPolicy.HttpsRedirectionMiddleware[1] Redirecting to 'https://shop.example.test/whoami'.

The first line shows which address to trust. One category at Debug in appsettings.json is enough to see it:

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning",
      "Microsoft.AspNetCore.HttpOverrides": "Debug"
    }
  }
}

An issue in the ASP.NET Core repository has been open since October 2024 asking for a more prominent error when a proxy is unknown.

The fix

Configure the app to trust the load balancer's IP address. Use the address the app sees when the load balancer connects, and keep the flags your app already uses:

using System.Net;
using Microsoft.AspNetCore.HttpOverrides;

builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
    options.ForwardedHeaders = ForwardedHeaders.XForwardedProto;
    options.KnownProxies.Add(IPAddress.Parse("10.0.0.4"));
});

If the load balancer's address changes, trust a subnet that holds only your load balancers. Anything else in a trusted subnet can write these headers too. On .NET 10, use KnownIPNetworks; KnownNetworks is obsolete:

// .NET 10
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
    options.ForwardedHeaders = ForwardedHeaders.XForwardedProto;
    options.KnownIPNetworks.Add(System.Net.IPNetwork.Parse("10.0.0.0/28"));
});
// .NET 8 and 9
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
    options.ForwardedHeaders = ForwardedHeaders.XForwardedProto;
    options.KnownNetworks.Add(new Microsoft.AspNetCore.HttpOverrides.IPNetwork(IPAddress.Parse("10.0.0.0"), 28));
});

If you also use the client address, add ForwardedHeaders.XForwardedFor. The middleware then reads only the rightmost entry by default (ForwardLimit is 1), the one your load balancer added, so make sure it appends its own entry. Behind a chain of proxies, each one must append its entry and be trusted; raising ForwardLimit alone trusts nothing.

With the stand-in's address in KnownProxies, the app worked on all three runtimes, forwarding the scheme only or both headers. The caller that bypassed the load balancer was ignored and redirected to HTTPS. We did not test the subnet variant.

Check your own services

  1. Check which ASP.NET Core runtime you run in production. Use dotnet --list-runtimes on the host, or check your base image tag. The change is in 8.0.17, 9.0.6 and later, and in .NET 10.
  2. Search for UseForwardedHeaders and ForwardedHeaders =. If the flags include XForwardedProto or XForwardedHost but not XForwardedFor, your app is in the group the patch changed: check that the address your load balancer connects from is in the trusted lists.
  3. Add the load balancer's address to KnownProxies, or its subnet to KnownIPNetworks (KnownNetworks on 8 and 9). If you need to find the address, set Microsoft.AspNetCore.HttpOverrides to Debug and look for Unknown proxy.
  4. Search your configuration, environment and code for ASPNETCORE_FORWARDEDHEADERS_ENABLED, IgnoreUnknownProxiesWithoutFor, MICROSOFT_ASPNETCORE_HTTPOVERRIDES_IGNORE_UNKNOWN_PROXIES_WITHOUT_FOR, KnownProxies.Clear(), KnownNetworks.Clear() and KnownIPNetworks.Clear(). Clearing both lists turns the address check off, and the switch brings back the old behavior. Each can let forwarded headers in from sources the app does not know, so check that only the load balancer can reach the app. The switch stops working on .NET 10.
  5. After the next patch, test through the real load balancer. A page that requires sign-in should redirect to an https login URL, and the authentication cookie should carry secure.

What we measured

  • One untrusted load balancer was enough to stop sign-in on all three runtimes, and the template's log levels recorded nothing.
  • Every opt-out that worked on .NET 10 also believed callers that skipped the load balancer.
  • The compatibility switch helps only on .NET 8 and 9.
  • Of everything we tried, only naming the load balancer in KnownProxies fixed sign-in and kept direct callers out.

How we ran this

We used .NET 8.0.33, 9.0.22 and 10.0.14, with matching ASP.NET Core versions, and SDK 10.0.403 on Windows 11. One app, built for the three target frameworks, used only the shared framework. Each scenario started a fresh app process listening only on 127.0.0.1.

A client in a separate process acted as the TLS-terminating load balancer. It connected from 127.0.0.2 and sent plain HTTP with the host shop.example.test, X-Forwarded-Proto: https and X-Forwarded-For: 203.0.113.7, following redirects up to ten times. A second client connected from 127.0.0.3 as the caller that bypasses the load balancer. There was no TLS anywhere: the HTTPS came only from the header, as it does for an app behind a real load balancer.

All traffic stayed on loopback. A real load balancer connects from an address outside the default lists; to reproduce that, we removed 127.0.0.0/8 from the trusted networks, making our stand-in at 127.0.0.2 an unknown proxy. The trusted case kept the shipped lists; the fix added 127.0.0.2 to KnownProxies. We did not install 8.0.16 or 9.0.5: the old behavior here comes from the documented compatibility switch on 8.0.33 and 9.0.22.

We kept the ASP.NET Core template's logging levels except in one scenario, where we raised two categories to Debug. We ran every scenario twice, and the outcomes matched across both runs. In both full runs, the Debug scenario on .NET 10 failed to start because its port was in a range reserved by Windows. We moved the port range and ran that scenario twice more.