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 produced | Load balancer trusted | Load balancer unknown |
|---|---|---|
Request.Scheme | https | http |
Link built with LinkGenerator | https | http |
| Redirect to the login page | https | http |
Secure on the authentication cookie | yes | no |
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 loopback | works | works | works |
| Load balancer not in the lists | loop | loop | loop |
AppContext switch IgnoreUnknownProxiesWithoutFor | works | works | loop |
Its environment variable, ..._IGNORE_UNKNOWN_PROXIES_WITHOUT_FOR=true | works | works | loop |
ASPNETCORE_FORWARDEDHEADERS_ENABLED=true | works | works | works |
| Both lists cleared in code | works | works | works |
Load balancer's address in KnownProxies | works | works | works |
"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.
| Configuration | Scheme the app saw | Client address the app saw |
|---|---|---|
Load balancer's address in KnownProxies | headers ignored, redirected to HTTPS | endpoint not reached |
| Compatibility switch, .NET 8 and 9 | https | 127.0.0.3 |
ASPNETCORE_FORWARDEDHEADERS_ENABLED=true | https | 127.0.0.3 |
| Lists cleared, scheme only | https | 127.0.0.3 |
Lists cleared, X-Forwarded-For forwarded too | https | 198.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
- Check which ASP.NET Core runtime you run in production. Use
dotnet --list-runtimeson the host, or check your base image tag. The change is in 8.0.17, 9.0.6 and later, and in .NET 10. - Search for
UseForwardedHeadersandForwardedHeaders =. If the flags includeXForwardedProtoorXForwardedHostbut notXForwardedFor, your app is in the group the patch changed: check that the address your load balancer connects from is in the trusted lists. - Add the load balancer's address to
KnownProxies, or its subnet toKnownIPNetworks(KnownNetworkson 8 and 9). If you need to find the address, setMicrosoft.AspNetCore.HttpOverridesto Debug and look forUnknown proxy. - Search your configuration, environment and code for
ASPNETCORE_FORWARDEDHEADERS_ENABLED,IgnoreUnknownProxiesWithoutFor,MICROSOFT_ASPNETCORE_HTTPOVERRIDES_IGNORE_UNKNOWN_PROXIES_WITHOUT_FOR,KnownProxies.Clear(),KnownNetworks.Clear()andKnownIPNetworks.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. - After the next patch, test through the real load balancer. A page that requires sign-in should redirect to an
httpslogin URL, and the authentication cookie should carrysecure.
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
KnownProxiesfixed 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.