Mert Özen Further With Every Line
DevOps Web Servers and Hosting — #03

ASP.NET Core Behind a Reverse Proxy: Forwarded Headers, KnownIPNetworks and the Real Client IP

Mert Özen Nov 8, 2026 11 min 4 views
ASP.NET Core Behind a Reverse Proxy: Forwarded Headers, KnownIPNetworks and the Real Client IP

An app behind a proxy sees the client's IP and HTTPS only if UseForwardedHeaders is set up correctly: you must say which headers to process and add the proxy's address to KnownProxies or, on .NET 10, KnownIPNetworks. With real runs we look at the HTTPS redirection loop, the ASPDEPR005 warning, fake X-Forwarded-For values and ForwardLimit, PathBase via X-Forwarded-Prefix and Data Protection keys across multiple instances.

Version note: The examples in this post were run with .NET 10 (ASP.NET Core runtime 10.0.14), Nginx 1.30.6 and curl 8.23 on Docker 29.

Guess First

Bookstore.Api now sits behind Nginx. Nginx terminates TLS (Transport Layer Security) on 443 and forwards the request to Kestrel over plain HTTP (Hypertext Transfer Protocol). The app has two lines from the ASP.NET Core template: app.UseHsts() and app.UseHttpsRedirection(). ASPNETCORE_HTTPS_PORT=443 is set as an environment variable. A user opens https://bookstore.test/api/books/1 in the browser.

Guess: does the user see the book? Nginx already sends the X-Forwarded-Proto header; is that enough for the app to know the request arrived over HTTPS (HTTP Secure)? And what would happen if that environment variable weren't set?

What the Proxy Hides, Which Header Carries What

As you saw in the first post of the series, an app behind Nginx sees Nginx, not the client. Four pieces of information get lost, and an established header carries each of them:

HeaderInformation carriedValue it corrects in ASP.NET Core
X-Forwarded-ForThe client's IP (Internet Protocol) address and the proxies on the way, left to rightHttpContext.Connection.RemoteIpAddress
X-Forwarded-ProtoThe scheme the client used: http or httpsHttpContext.Request.Scheme
X-Forwarded-HostThe original Host the client sentHttpContext.Request.Host
X-Forwarded-PrefixThe base path the proxy strippedHttpContext.Request.PathBase

The piece that reads these headers and corrects HttpContext is the forwarded headers middleware (ForwardedHeadersMiddleware). The most important sentence on the "Configure ASP.NET Core to work with proxy servers and load balancers" page on Microsoft Learn is this: by default the middleware processes no headers (ForwardedHeaders.None), trusts only proxies coming from loopback addresses (127.0.0.0/8 and [::1]) and assumes there's a single proxy in the chain. These three defaults explain every experiment below.

UseForwardedHeaders: Setup and Middleware Order

In Bookstore.Api the forwarded headers setup is driven by configuration, so the experiments can switch it on and off with environment variables:

string mode = builder.Configuration["Proxy:Mode"] ?? "none";
if (mode != "none")
{
    builder.Services.Configure<ForwardedHeadersOptions>(options =>
    {
        options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto
            | ForwardedHeaders.XForwardedHost | ForwardedHeaders.XForwardedPrefix;
        foreach (string cidr in (builder.Configuration["Proxy:KnownNetworks"] ?? "").Split(';', StringSplitOptions.RemoveEmptyEntries))
        {
            options.KnownIPNetworks.Add(System.Net.IPNetwork.Parse(cidr));
        }
        if (int.TryParse(builder.Configuration["Proxy:ForwardLimit"], out int limit))
        {
            options.ForwardLimit = limit;
        }
    });
}

WebApplication app = builder.Build();

if (mode != "none")
{
    app.UseForwardedHeaders();
}
if (builder.Configuration["Https:Redirect"] == "true")
{
    app.UseHsts();
    app.UseHttpsRedirection();
}

Order matters: UseForwardedHeaders must come before every middleware that looks at the scheme, the host or the client IP. HSTS (HTTP Strict Transport Security), HTTPS redirection, authentication, rate limiting and HTTP logging all have to see the corrected values. According to the docs it can come after diagnostic middleware such as exception handling, but it must come before UseHsts. The general logic of pipeline order is in the "Middleware Pipeline" post of the ASP.NET Core Web API (Application Programming Interface) series.

The Nginx config is the TLS version of the one from the first post. The same API is also published under the /bookstore/ sub-path; we'll need that shortly:

upstream bookstore_api {
    server 172.30.85.21:8080;
}

server {
    listen 443 ssl;
    server_name bookstore.test;
    ssl_certificate     /certs/bookstore.test.crt;
    ssl_certificate_key /certs/bookstore.test.key;

    location /api/ {
        proxy_pass http://bookstore_api;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    # the same API published under a sub-path
    location /bookstore/ {
        proxy_pass http://bookstore_api/;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-Prefix /bookstore;
    }
}

Three runs: no middleware, middleware with default settings, middleware with Nginx's address in the trusted list. Nginx is at 172.30.85.10, the client at 172.30.85.50:

$ curl -s --cacert ca.crt https://bookstore.test/api/whoami | jq -c '{remoteIp,scheme,host,pathBase,path,xForwardedFor,xOriginalFor,link}'

# A: no Proxy__Mode
{"remoteIp":"::ffff:172.30.85.10","scheme":"http","host":"bookstore.test","pathBase":"","path":"/api/whoami","xForwardedFor":"172.30.85.50","xOriginalFor":"","link":"http://bookstore.test/api/books/1"}

# B: Proxy__Mode=on, KnownIPNetworks empty (loopback only)
{"remoteIp":"::ffff:172.30.85.10","scheme":"http","host":"bookstore.test","pathBase":"","path":"/api/whoami","xForwardedFor":"172.30.85.50","xOriginalFor":"","link":"http://bookstore.test/api/books/1"}

# C: Proxy__Mode=on, Proxy__KnownNetworks=172.30.85.10/32
{"remoteIp":"172.30.85.50","scheme":"https","host":"bookstore.test","pathBase":"","path":"/api/whoami","xForwardedFor":"","xOriginalFor":"[::ffff:172.30.85.10]:56566","link":"https://bookstore.test/api/books/1"}

The silence in B is the case that wastes the most time: the middleware is added, the code looks right, but nothing changes. The reason is logged only at Debug level:

dbug: Microsoft.AspNetCore.HttpOverrides.ForwardedHeadersMiddleware[1]
      Unknown proxy: [::ffff:172.30.85.10]:56560

If Nginx connected from 127.0.0.1 on the same machine, the defaults would be enough. In a container, on a separate VM (virtual machine) or behind a load balancer the proxy's address isn't loopback and the middleware ignores the headers it sends. In C, once Nginx is in the trusted list, everything falls into place: RemoteIpAddress is the client's address, the scheme is https, the link is correct. The middleware removes the value it used from X-Forwarded-For and writes the old value to X-Original-For; keep that in mind when you log headers later.

KnownProxies and KnownIPNetworks in .NET 10

There are two ways to define trusted proxies: KnownProxies takes individual IP addresses, KnownIPNetworks takes CIDR (Classless Inter-Domain Routing) blocks. Before .NET 10 the latter was called KnownNetworks and used the Microsoft.AspNetCore.HttpOverrides.IPNetwork type. After .NET itself gained System.Net.IPNetwork, the copy in ASP.NET Core became obsolete. Compiling the old code with .NET 10:

// pre-.NET 10 usage
options.KnownNetworks.Add(new Microsoft.AspNetCore.HttpOverrides.IPNetwork(IPAddress.Parse("10.0.0.0"), 8));
Program.cs(9,5): warning ASPDEPR005: 'ForwardedHeadersOptions.KnownNetworks' is obsolete: 'Please use KnownIPNetworks instead. For more information, visit https://aka.ms/aspnet/deprecate/005.' (https://aka.ms/aspnet/deprecate/005)
Program.cs(9,35): warning ASPDEPR005: 'IPNetwork' is obsolete: 'Please use System.Net.IPNetwork instead. For more information, visit https://aka.ms/aspnet/deprecate/005.' (https://aka.ms/aspnet/deprecate/005)

It's a warning, so the code keeps working; the breaking change document classifies it as a "source compatibility" impact. The new form is one line: options.KnownIPNetworks.Add(System.Net.IPNetwork.Parse("10.0.0.0/8")). If a file has both the System.Net and the Microsoft.AspNetCore.HttpOverrides namespaces open, the name IPNetwork matches two types; that's why I wrote the full name in Bookstore.Api.

The docs say that with dual-mode sockets IPv4 addresses arrive in IPv6 form, like ::ffff:10.0.0.1, and may need to be added to the list in that form. In run C, RemoteIpAddress arrived as ::ffff:172.30.85.10 and still matched 172.30.85.10/32; on .NET 10 the IPv4 CIDR was enough. If you see the "Unknown proxy" log in your own environment, this format difference is still the first place to look.

There's also a change that came with a servicing release: since ASP.NET Core 8.0.17 and 9.0.6, even when X-Forwarded-For isn't processed, the middleware ignores X-Forwarded-Proto, X-Forwarded-Host and X-Forwarded-Prefix from unknown proxies too. An app that used to say "the proxy list is empty but the scheme comes through fine" dropped into the state of run B after that update, and some teams saw it as an HTTPS redirection loop.

The HTTPS Redirection Loop and HSTS

Now back to the opening question. No middleware, UseHttpsRedirection on and ASPNETCORE_HTTPS_PORT=443:

$ curl -sS -L -o /dev/null --max-redirs 5 -w "%{http_code} redirects=%{num_redirects}\n" https://bookstore.test/api/books/1
307 redirects=5
curl: (47) Maximum (5) redirects followed

$ curl -s -D - -o /dev/null https://bookstore.test/api/books/1
HTTP/1.1 307 Temporary Redirect
Server: nginx/1.30.6
Date: Sun, 08 Nov 2026 03:45:07 GMT
Content-Length: 0
Connection: keep-alive
Location: https://bookstore.test/api/books/1

The user comes in over HTTPS, Nginx terminates TLS and forwards to Kestrel over HTTP, the app looks at Request.Scheme, says "this request is HTTP" and redirects to the HTTPS version of the same address. After a few rounds the browser gives up with "too many redirects". The X-Forwarded-Proto: https header was there on every request; nobody read it.

The second part of the question is sneakier. Without ASPNETCORE_HTTPS_PORT and without an HTTPS endpoint of Kestrel's own, the middleware can't tell which port to redirect to:

$ curl -s -o /dev/null -w "%{http_code}\n" https://bookstore.test/api/books/1
200

warn: Microsoft.AspNetCore.HttpsPolicy.HttpsRedirectionMiddleware[3]
      Failed to determine the https port for redirect.

No loop this time, but no redirection either: if someone bypassed Nginx and came to Kestrel over HTTP, the app wouldn't move them to HTTPS. In this configuration that "works", the security setting is effectively off. With the middleware on and Nginx in the trusted list:

$ curl -s -D - https://bookstore.test/api/books/1
HTTP/1.1 200 OK
Server: nginx/1.30.6
Date: Sun, 08 Nov 2026 03:45:14 GMT
Content-Type: application/json; charset=utf-8
Transfer-Encoding: chunked
Connection: keep-alive
Strict-Transport-Security: max-age=2592000

{"id":1,"title":"Dune","author":"Frank Herbert","price":12.50}

The loop is gone and we gained a header: UseHsts adds Strict-Transport-Security only to HTTPS requests, and now it knows the request is HTTPS. max-age=2592000 is ASP.NET Core's default of 30 days. Whether to send HSTS from the app or from Nginx, why not both, and the other security headers are covered in the seventh post; HSTS itself is in the "CORS (Cross-Origin Resource Sharing) and Security: HTTPS, HSTS, Headers and Secrets" post of the ASP.NET Core Web API series.

Having the app itself redirect to HTTPS is a debatable choice behind a proxy. If TLS ends at Nginx, doing the HTTP to HTTPS redirect at Nginx as well (a one-line return 301 on port 80) is simpler and removes the risk of the app misreading the scheme. Microsoft's Nginx documentation, on the other hand, recommends the middleware in the app. Whichever you pick, don't do both, and set up forwarded headers correctly in every case: link generation, the Secure flag on cookies and OAuth (Open Authorization) redirect addresses also look at Request.Scheme.

Whom Should You Trust with Headers?

X-Forwarded-For is a plain HTTP header; the client can write whatever it likes. $proxy_add_x_forwarded_for in Nginx appends the address it sees to the end of the value coming from the client. If the client sends X-Forwarded-For: 6.6.6.6, Kestrel receives 6.6.6.6, 172.30.85.50. The middleware reads the list right to left and processes up to ForwardLimit values; at each step it checks whether the address that handed over the value on the right is trusted. Three runs, each with the client sending a fake address:

$ curl -s -H "X-Forwarded-For: 6.6.6.6" https://bookstore.test/api/whoami | jq -c '{remoteIp,scheme,xForwardedFor,xOriginalFor}'

# F-1: KnownNetworks=172.30.85.0/24 (the client is on this network too), ForwardLimit=2
{"remoteIp":"6.6.6.6","scheme":"https","xForwardedFor":"","xOriginalFor":"[::ffff:172.30.85.10]:51284"}

# F-2: KnownNetworks=172.30.85.10/32, ForwardLimit default (1)
{"remoteIp":"172.30.85.50","scheme":"https","xForwardedFor":"6.6.6.6","xOriginalFor":"[::ffff:172.30.85.10]:51292"}

# F-2b: KnownNetworks=172.30.85.10/32, ForwardLimit=2
{"remoteIp":"172.30.85.50","scheme":"https","xForwardedFor":"6.6.6.6","xOriginalFor":"[::ffff:172.30.85.10]:51302"}

In F-1 the attacker appears with whatever IP they want. Two mistakes came together: the trusted network is wide enough to include clients, and ForwardLimit=2 lets the middleware look at a second value. The middleware takes 172.30.85.50 on the right for "another trusted proxy", goes one step further left and accepts 6.6.6.6. F-2b shows that keeping the trusted list narrow is enough even with a limit of 2: 172.30.85.50 isn't trusted, so the middleware stops there and leaves the fake value in X-Forwarded-For.

The rule is simple: the trusted list should contain only proxies that are really yours, and ForwardLimit should equal the number of proxies in the chain. With a CDN (Content Delivery Network) in front, a cloud load balancer behind it and Nginx behind that, there are three layers; you need to know the address range of each.

The ASPNETCORE_FORWARDEDHEADERS_ENABLED=true environment variable, mentioned in the docs as a shortcut, adds the middleware automatically with XForwardedFor | XForwardedProto and clears the trusted lists. In other words, it trusts everyone. A client that bypasses Nginx and talks straight to Kestrel:

$ curl -s -H "X-Forwarded-For: 6.6.6.6" -H "X-Forwarded-Proto: https" http://172.30.85.21:8080/api/whoami | jq -c '{remoteIp,scheme,xForwardedFor,xOriginalFor}'
{"remoteIp":"6.6.6.6","scheme":"https","xForwardedFor":"","xOriginalFor":"[::ffff:172.30.85.50]:42474"}

A request that came over plain HTTP looks like HTTPS to the app, with a fake IP. The docs warn explicitly that this variable uses settings designed for cloud environments and doesn't turn on restrictions such as KnownProxies. It's useful only if the platform's own load balancer is the only thing that can reach Kestrel; if the port is reachable from anywhere else, don't use it. If IP-based rate limiting, an IP allowlist or an audit log relies on this value, a fake header turns straight into a security hole; a typical example of the misconfiguration described in the "OWASP (Open Worldwide Application Security Project) A02: Security Misconfiguration" post of the Web Security series.

There's also the Unix socket case. As you saw in the previous post, RemoteIpAddress is null for a request coming over the socket. In that setup I turned the middleware on without any KnownIPNetworks:

{"remoteIp":"172.30.85.50","scheme":"http","xForwardedFor":"","xOriginalFor":""}

The headers were processed; with no IP to check, the middleware didn't reject the proxy. On a Unix socket the trust boundary isn't an IP list but the socket file's permissions: any process that can write to the socket can forge the headers.

Sub-Path: X-Forwarded-Prefix and PathBase

Sometimes Bookstore's API isn't published on its own domain but under a sub-path such as https://bookstore.test/bookstore/. Because of the trailing slash in proxy_pass http://bookstore_api/;, Nginx strips the /bookstore/ prefix and sends /api/whoami to Kestrel. The app sees the right path for routing, but the links it generates lack the prefix. X-Forwarded-Prefix and ForwardedHeaders.XForwardedPrefix fix that:

$ curl -s https://bookstore.test/bookstore/api/whoami | jq -c '{remoteIp,scheme,host,pathBase,path,xForwardedFor,xOriginalFor,link}'
{"remoteIp":"172.30.85.50","scheme":"https","host":"bookstore.test","pathBase":"/bookstore","path":"/api/whoami","xForwardedFor":"","xOriginalFor":"[::ffff:172.30.85.10]:56576","link":"https://bookstore.test/bookstore/api/books/1"}

PathBase is /bookstore, Path is /api/whoami; the link is generated with the prefix. If the proxy doesn't strip the prefix, that is if /bookstore/api/whoami reaches Kestrel, the right tool is app.UsePathBase("/bookstore"), which has to be called before UseRouting. Exactly what the trailing slash in proxy_pass does is covered in the next post.

Multiple Instances: Data Protection Keys

The moment you put a second Kestrel instance behind the proxy, a problem unrelated to forwarded headers begins. ASP.NET Core's Data Protection system encrypts cookie authentication, antiforgery tokens and TempData; by default the keys live on each instance's own disk (in a container, /root/.aspnet/DataProtection-Keys, as the log warning says). What one instance encrypts, the other can't decrypt:

token: CfDJ8PGMJrZO0I9OvX1xWpP7Hr5nam2dvIr_Q92x...
api1: "cart-42"
api2: {"type":"https://tools.ietf.org/html/rfc9110#section-15.6.1","title":"An error occurred while processing your request.","status":500,"detail":"The key {b6268cf1-d04e-4e8f-bd7d-715a93fb1ebe} was not found in the key ring. For more information go to https://aka.ms/aspnet/dataprotectionwarning"}

For the user, this means the session drops or a form is rejected with an antiforgery error the moment the load balancer sends them to the other instance; and at random. The fix is to share the key ring and give both instances the same application name:

IDataProtectionBuilder dataProtection = builder.Services.AddDataProtection();
string? keysDir = builder.Configuration["DataProtection:KeysDirectory"];
if (keysDir is not null)
{
    dataProtection.SetApplicationName("Bookstore.Api")
        .PersistKeysToFileSystem(new DirectoryInfo(keysDir));
}
token: CfDJ8PVkjF3TFrtHjwYH7R3xaUy8qAYw6RU7G7g8...
api1: "cart-42"
api2: "cart-42"

In the experiment the shared folder was a Docker volume; the keys sit there as XML (Extensible Markup Language) files. In production it can be a shared file system, Redis, a database or Azure Blob Storage; add encryption with ProtectKeysWith* so the keys don't sit unencrypted on disk. The "No XML encryptor configured" warning in the log is saying exactly that. Sending the same user to the same instance with sticky sessions hides this problem but doesn't solve it: when the instance restarts, the keys are gone again.

In Production

Put the proxy topology in configuration, not in code. Trusted networks differ per environment: loopback in development, a container network in staging, the load balancer's subnet in production. A Proxy:KnownNetworks key as in Bookstore.Api makes it easy to have a separate value per environment.

The "Unknown proxy" message is at Debug level. Instead of relying on a log you won't see in production, add a request to your post-deploy check: call an endpoint like /api/whoami through the proxy and verify that scheme and remoteIp are the values you expect. Don't leave this endpoint open to the outside; it should be reachable only from the internal network or with authorization.

If you process X-Forwarded-Host, fill in the AllowedHosts list as well. If the list is empty every host is accepted; in an app that builds links from the host header, that's the way to get an attacker's domain into a password reset email. The proxy itself should reject unknown hosts too; in Nginx the way to do that is the default_server in the fourth post.

Common Pitfalls

Adding UseForwardedHeaders without setting ForwardedHeaders. The default is None; the middleware does nothing and raises no error.

Forgetting to add the proxy's address to the trusted list. It's needed for every proxy other than loopback; the symptom, as in run B, is "everything's right but nothing changes".

Keeping the trusted list wide or clearing it entirely. 0.0.0.0/0, a subnet that includes clients, KnownProxies.Clear() or ASPNETCORE_FORWARDEDHEADERS_ENABLED opens the door to the fake IP of runs F-1 and F-3.

Calling the middleware late in the pipeline. If authentication, HTTPS redirection or HTTP logging run before it, they see uncorrected values; the logs keep the proxy's address instead of the client IP.

Forgetting Data Protection when increasing the instance count. An error that never appears with one instance shows up as random session drops with the second.

A Small Experiment

★☆☆ Repeat run C with KnownProxies instead of Proxy__KnownNetworks: add options.KnownProxies.Add(IPAddress.Parse("172.30.85.10")) to the code. Does it work? Then give the address in the ::ffff:172.30.85.10 form and watch for the difference.

★★☆ Replace the proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; line in Nginx with $remote_addr and repeat run F-1. Does the fake IP still get through? What does this change break when there are several proxies in the chain?

★★★ Put two Kestrel instances behind Nginx with round robin, add cookie authentication to the app and sign in without sharing keys. On which request does your session drop? After sharing the keys, delete one instance and recreate it; is the user still signed in?

The app now sees the client correctly behind the proxy. Next up is the proxy itself: in the next post we install Nginx. The difference between the nginx.org repository and the distribution's package, master and worker processes, the structure of nginx.conf, the matching order of server and location blocks, the difference between root and alias, and the trailing slash in proxy_pass.