ASP.NET Core ships its own answer to the client-IP problem: the forwarded-headers middleware. It works well, it's free, and it fails silently in production for one specific reason that catches nearly everyone. Here's how to configure it properly, and where it stops helping.
Short answer. KnownProxies is the list of proxy IP addresses ASP.NET Core will accept X-Forwarded-For from. It defaults to loopback only (::1), so the middleware silently does nothing in production until you add your real proxy. Call UseForwardedHeaders first in the pipeline.
It rewrites HttpContext.Connection.RemoteIpAddress so your application sees the real client. It does not change the c-ip field in the IIS W3C logs, which IIS writes independently of your app. Correcting the log itself takes an ISAPI filter such as X-Forwarded-For for IIS.
It reads the X-Forwarded-For and X-Forwarded-Proto headers sent by a trusted proxy and overwrites HttpContext.Connection.RemoteIpAddress and Request.Scheme with the original client's values, before the rest of the pipeline runs.
When your app sits behind a reverse proxy, Kestrel sees the connection coming from that proxy, so HttpContext.Connection.RemoteIpAddress is the proxy's address and Request.Scheme is often http even though the client used HTTPS. The forwarded-headers middleware reads X-Forwarded-For and X-Forwarded-Proto and rewrites those properties before the rest of your pipeline runs.
Everything downstream (your logging, rate limiting, authorisation policies, audit records, Request.IsHttps) then sees the real client rather than the proxy.
var builder = WebApplication.CreateBuilder(args);
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders =
ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
// The defaults trust loopback only. Add your actual proxy.
options.KnownProxies.Add(IPAddress.Parse("10.0.0.5"));
// Or, for a range of proxies:
// options.KnownNetworks.Add(new IPNetwork(IPAddress.Parse("10.0.0.0"), 24));
});
var app = builder.Build();
// Must run before anything that reads the client IP or scheme.
app.UseForwardedHeaders();
app.UseAuthentication();
app.UseAuthorization();
On the older Startup.cs layout the same two halves live in ConfigureServices (the Configure<ForwardedHeadersOptions> call) and Configure (the app.UseForwardedHeaders() call). Nothing else changes.
KnownProxies holds individual proxy addresses and matches them exactly. KnownNetworks holds CIDR ranges and matches any address inside them. The middleware compares the address that opened the TCP connection against both lists, and processes the forwarded headers only if one of them matches.
| Option | Type | Default | Use it when |
|---|---|---|---|
KnownProxies |
IList<IPAddress> |
One entry: IPAddress.IPv6Loopback (::1) |
You have a handful of proxies at fixed addresses. |
KnownNetworks |
IList<IPNetwork> |
One entry: 127.0.0.0/8 |
Your proxies sit in a subnet, or their addresses change. |
ForwardLimit |
int? |
1 |
More than one trusted hop sits in front of the app. |
ForwardedForHeaderName |
string |
X-Forwarded-For |
Your proxy sends a different header, such as CF-Connecting-IP. |
Both defaults exist so that a developer machine works out of the box. Neither is any use in production, where the proxy is a different host. Note that the two defaults are not symmetric: KnownProxies ships the IPv6 loopback and KnownNetworks ships the IPv4 loopback range.
The most common case. Add each proxy address; adding one does not remove the loopback defaults, so local development keeps working.
options.KnownProxies.Add(IPAddress.Parse("10.0.0.5"));
options.KnownProxies.Add(IPAddress.Parse("10.0.0.6"));
When the load balancer pool can grow or its addresses are assigned dynamically, name the network instead of chasing individual addresses. The second argument is the CIDR prefix length, so this trusts 10.0.0.0/24:
options.KnownNetworks.Add(
new IPNetwork(IPAddress.Parse("10.0.0.0"), 24));
Hard-coding addresses means a rebuild every time the network changes. Reading them from appsettings.json keeps the trust list an operational setting rather than a code change, which matters when the same build is deployed to several environments:
// appsettings.json
// "TrustedProxies": [ "10.0.0.5", "10.0.0.6" ]
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders =
ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
foreach (var ip in builder.Configuration
.GetSection("TrustedProxies").Get<string[]>() ?? [])
{
options.KnownProxies.Add(IPAddress.Parse(ip));
}
});
Usually because the address you added is not the one opening the connection. The middleware compares the TCP peer of the request, which is often a private interface or SNAT address on the load balancer rather than the address you know it by, or a second proxy hop you did not realise was there. Log HttpContext.Connection.RemoteIpAddress on a real request through the proxy and trust exactly that address. The second common cause is a trust list that never loaded at all, covered below.
Dual-stack sockets are not the problem, whatever older advice says. On a dual-stack socket an IPv4 proxy at 10.0.0.5 does arrive as ::ffff:10.0.0.5, and Microsoft's options table still suggests checking whether you need that form. But since ASP.NET Core 2.2 the middleware maps an IPv4-mapped address back to IPv4 before checking both lists. We tested it on .NET 8.0.31 and .NET 10.0.12:
| Trusted entry | Connection arrives from | Headers applied? |
|---|---|---|
KnownProxies: 10.0.0.5 |
::ffff:10.0.0.5 |
Yes |
KnownNetworks / KnownIPNetworks: 10.0.0.0/24 |
::ffff:10.0.0.5 |
Yes |
KnownProxies: ::ffff:10.0.0.5 |
10.0.0.5 |
No |
So add proxies in plain IPv4 form. The mapping only runs in one direction, which means an entry added as ::ffff:10.0.0.5 (or with MapToIPv6()) stops matching if the host ever accepts the connection on an IPv4 socket instead.
Turn on debug logging for the middleware. When it stops processing because a connection did not come from a trusted address, it writes a Debug entry reading Unknown proxy: 10.0.0.7:51234, with the real peer address and port. That one line answers most "KnownProxies not working" questions, because it names the exact address to trust. It is logged at Debug rather than Warning, which is why nothing appears at the default log levels.
// appsettings.Development.json
{
"Logging": {
"LogLevel": {
"Microsoft.AspNetCore.HttpOverrides": "Debug"
}
}
}
A companion message, Unparsable IP, means a value in the header could not be read as an address, which is worth checking when a proxy writes ports or brackets into it. Turn the level back down once you have the address: at Debug, every untrusted request writes a line.
Yes. In .NET 10, ForwardedHeadersOptions.KnownNetworks and Microsoft.AspNetCore.HttpOverrides.IPNetwork are obsolete. Building against them raises warning ASPDEPR005. The replacements are KnownIPNetworks and the framework's own System.Net.IPNetwork:
// .NET 10 and later
options.KnownIPNetworks.Add(
System.Net.IPNetwork.Parse("10.0.0.0/24"));
// .NET 9 and earlier
options.KnownNetworks.Add(
new IPNetwork(IPAddress.Parse("10.0.0.0"), 24));
KnownProxies is unaffected and works the same on every version. If your editor reports IPNetwork as an ambiguous reference, that is the two types colliding: System.Net.IPNetwork and the older Microsoft.AspNetCore.HttpOverrides.IPNetwork are both in scope. Qualify the one you mean, or move to KnownIPNetworks and drop the HttpOverrides using directive.
Point the middleware at the header it does send. Cloudflare uses CF-Connecting-IP, some appliances use True-Client-IP, and older Java stacks use Proxy-Client-IP. Set the name and the rest of the configuration is unchanged:
options.ForwardedForHeaderName = "CF-Connecting-IP";
Only one header name can be read at a time, so if traffic can arrive through two different proxies you will need to normalise upstream. Our reference of which client IP header each proxy sends lists the common ones.
Not by name, but it reads it once you point it there. There is no XRealIp flag; set the header name and keep the XForwardedFor flag, which controls the client-address processing whatever the header is called:
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
options.ForwardedForHeaderName = "X-Real-IP";
options.KnownProxies.Add(IPAddress.Parse("10.0.0.5")); // the Nginx host
This is the usual case behind Nginx configured with proxy_set_header X-Real-IP $remote_addr;. X-Real-IP holds a single address rather than a chain, so the default ForwardLimit of 1 is right, and the trust check on the connecting proxy is unchanged. Nginx only sets X-Forwarded-Proto if you add it too, so include proxy_set_header X-Forwarded-Proto $scheme; or the scheme stays http. The standard Forwarded header from RFC 7239 is a different matter: the middleware does not parse it at all, so a proxy sending only Forwarded needs to be switched to the X-Forwarded-* headers.
There is a second way to enable the middleware, and it is the one most people meet first in containers and on Azure App Service. Set the environment variable and the host wires the middleware up for you, with no Program.cs change at all:
ASPNETCORE_FORWARDEDHEADERS_ENABLED=true
That switches on XForwardedFor | XForwardedProto exactly as the code above does. What is much less obvious is the rest of what it does: because you have opted in explicitly, it also clears KnownProxies and KnownNetworks. That is the same trust-everything posture the previous section warns against, arrived at by setting one environment variable rather than by writing Clear().
In a container whose only reachable ingress is a load balancer you control, that is usually acceptable, and it is why the shortcut exists. It stops being acceptable the moment anything else can open a connection to the app directly, because then any caller can assert whatever X-Forwarded-For they like. If your app is reachable beyond the proxy, configure ForwardedHeadersOptions in code and name your proxies instead.
Not by binding alone, and it fails silently. The obvious approach is to bind a configuration section to the options and supply the lists the way ASP.NET Core maps environment variables onto configuration keys, with double underscores and a numeric index. Microsoft documents no configuration keys for the trust lists, and in our testing on .NET 8.0.31 and .NET 10.0.12 the binder takes the simple settings and leaves the lists untouched:
// Environment:
// ForwardedHeaders__ForwardedHeaders=XForwardedFor, XForwardedProto
// ForwardedHeaders__ForwardLimit=2
// ForwardedHeaders__KnownProxies__0=10.0.0.5
// ForwardedHeaders__KnownNetworks__0=10.0.0.0/24
builder.Services.Configure<ForwardedHeadersOptions>(
builder.Configuration.GetSection("ForwardedHeaders"));
// What the options actually contain:
// ForwardedHeaders XForwardedFor, XForwardedProto bound
// ForwardLimit 2 bound
// KnownProxies [ ::1 ] unchanged
// KnownNetworks [ 127.0.0.0/8 ] unchanged
There is no exception, no warning and no log entry. The middleware is switched on, and it still trusts loopback only, which is the production failure described above reached by a different route. ForwardedHeaders__KnownIPNetworks__0 on .NET 10 is ignored the same way. The same thing happens with the lists in appsettings.json, since environment variables and JSON feed the same configuration.
The fix is to keep the keys and parse the lists yourself. Binding the section first still picks up the simple settings:
var section = builder.Configuration.GetSection("ForwardedHeaders");
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
section.Bind(options); // ForwardedHeaders, ForwardLimit and the other simple settings
foreach (var ip in section.GetSection("KnownProxies").Get<string[]>() ?? [])
options.KnownProxies.Add(IPAddress.Parse(ip));
// .NET 10 and later
foreach (var cidr in section.GetSection("KnownIPNetworks").Get<string[]>() ?? [])
options.KnownIPNetworks.Add(System.Net.IPNetwork.Parse(cidr));
});
With that in place the environment variables behave the way they look like they should: ForwardedHeaders__KnownProxies__0=10.0.0.5, ForwardedHeaders__KnownProxies__1=10.0.0.6, ForwardedHeaders__KnownIPNetworks__0=10.0.0.0/24. IPv6 ranges parse the same way, for example 2001:db8::/32. On .NET 9 and earlier, read a KnownNetworks key instead, split each value on the /, and add new IPNetwork(IPAddress.Parse(prefix), prefixLength) to KnownNetworks. Whichever version you are on, check the result once at startup: log the contents of both lists, because a list that failed to load looks exactly like a list that loaded.
Because the request reached the app as HTTP and nothing told it otherwise. The proxy terminates TLS and forwards plain HTTP, so Request.IsHttps is false, and OpenIddict's server rejects any request to one of its endpoints on that basis with error ID2083. The same wrong scheme also shows up in the absolute URLs OpenIddict builds from the request, which is why the discovery document can advertise http:// endpoints behind a proxy. Other identity components that check the scheme fail the same way.
The fix is the forwarded-headers configuration on this page, with three details that matter here:
ForwardedHeaders.XForwardedProto must be in the flags, and the proxy must actually send X-Forwarded-Proto: https.KnownProxies or KnownIPNetworks. An untrusted proxy is skipped completely, so the scheme is left alone as well as the address.UseForwardedHeaders() must run before UseAuthentication(), UseAuthorization() and the OpenIddict endpoints, otherwise they have already seen http.Forum answers often suggest options.AddServer().UseAspNetCore().DisableTransportSecurityRequirement() instead. That silences the error without fixing the scheme, so the generated URLs stay wrong, and it removes a check that exists to keep tokens off unencrypted connections. Keep it for local development.
This is the one that catches almost everyone. Out of the box KnownProxies and KnownNetworks contain only 127.0.0.1 / ::1. It works perfectly on a developer machine where the proxy is local, then does nothing at all in production, where the proxy is a different host; the middleware sees an untrusted forwarder and declines to rewrite anything. There's no error; the address is simply still wrong.
The fix is to add the proxy's real address. The temptation, widely repeated in forum answers, is to clear both collections instead:
// Don't do this.
options.KnownProxies.Clear();
options.KnownNetworks.Clear();
That makes it work by trusting any forwarder, which means anyone who can reach your app can set X-Forwarded-For to whatever they like and your logs, rate limits and audit trail will believe them. It converts a silent no-op into a silent spoofing hole.
UseForwardedHeaders has to run before any middleware that depends on the client address or the scheme. Placed after UseAuthentication, UseHttpsRedirection or your logging middleware, those components have already made decisions using the proxy's address.
ForwardLimit defaults to 1, meaning only the last entry in the chain is processed. Behind two hops (a CDN in front of a load balancer, say) you'll resolve to the intermediate proxy rather than the client. Raise it to match the number of trusted hops, and trust every one of them explicitly. Don't set it to null (unlimited) unless you have a clear reason.
No. The middleware only changes what your application sees. IIS writes the c-ip field in its W3C log from the TCP connection it accepted, before your application runs and independently of it, so the log keeps showing the proxy.
The middleware fixes your application's view of the client. It doesn't touch the IIS logs. IIS writes the W3C log entry (including the c-ip field) from the TCP connection it accepted, independently of whatever your application later concludes. So after configuring forwarded headers correctly you can be in the position where your app logs the right address and C:\inetpub\logs\LogFiles\ still shows the proxy for every request.
Whether that matters depends on what reads your logs:
| What you need | Right tool | Why |
|---|---|---|
| The app to see the real client: auth, rate limiting, app-level logging | UseForwardedHeaders |
Built into ASP.NET Core, no licence, no extra component. If this is all you need, you're done. |
IIS W3C logs correct: SIEM, compliance, log analysers, anything reading c-ip |
An ISAPI filter such as X-Forwarded-For for IIS | The middleware can't write to the IIS log. A filter rewrites c-ip before IIS records it, so downstream tooling needs no changes. |
| Both, or a mix of .NET Core and other apps on the same servers | Both, together | They operate at different layers and don't conflict. Classic ASP.NET, PHP and static sites on the same IIS instance get nothing from the middleware. |
If you own the application code, only care about the application's behaviour, and every app on the server is ASP.NET Core, the built-in middleware is the right answer and there's nothing to buy. The filter earns its place when the log file itself has to be correct, which is usually the case where a SIEM, a compliance requirement or an existing log-analysis pipeline is involved, none of which are going to be rewritten to read your application's logs instead.
Because KnownProxies and KnownNetworks default to loopback only. Locally the proxy is on the same machine so it's trusted; in production it's a different host, the middleware treats the forwarder as untrusted, and silently leaves the address unchanged. Add the production proxy's address explicitly.
No. Clearing them trusts any source of the X-Forwarded-For header, so anyone able to reach your application can forge a client address. It's a common suggestion in forum answers and it turns a configuration problem into a security one. Add your specific proxies instead.
Setting it to true enables the forwarded-headers middleware without any code change, which is why it is the usual approach in containers and on Azure App Service. It turns on XForwardedFor and XForwardedProto, and it also clears KnownProxies and KnownNetworks, so every forwarder is trusted. That is fine when the app can only be reached through a proxy you control, and unsafe when it can be reached directly. If in doubt, configure ForwardedHeadersOptions in code and list your proxies.
As early as possible, and definitely before authentication, HTTPS redirection and any request logging. Anything running before it sees the proxy's address rather than the client's.
No. IIS writes the c-ip field from the connection it accepted, before and independently of your application. The middleware only changes what your application sees. Correcting the IIS log itself needs a filter that rewrites the field as IIS records it.
It caps how many entries in the forwarded-header chain the middleware will process, defaulting to 1. With more than one trusted proxy in front of the app, raise it to the number of hops and add each of them to the trusted collections, so the middleware walks back to the genuine client rather than stopping at an intermediate proxy.
KnownProxies is a list of individual IP addresses matched exactly. KnownNetworks is a list of CIDR ranges matched by containment. Use KnownProxies for a few proxies at fixed addresses, and KnownNetworks when the proxies sit in a subnet or their addresses change.
A single entry, IPAddress.IPv6Loopback (::1). KnownNetworks separately defaults to a single entry covering 127.0.0.0/8. Together they trust the local machine only, which is why the middleware works in development and appears to do nothing once a real proxy is in front of it.
Usually because the address you added is not the TCP peer: a load balancer often connects from a private or SNAT address, or there is a second hop in front. Log HttpContext.Connection.RemoteIpAddress on a real proxied request and trust exactly that. Dual-stack sockets are not the cause on any current version: since ASP.NET Core 2.2 the middleware maps an IPv4-mapped address such as ::ffff:10.0.0.5 back to 10.0.0.5 before matching, so add proxies in plain IPv4 form.
Not by binding a configuration section. Variables such as ForwardedHeaders__KnownProxies__0 or ForwardedHeaders__KnownNetworks__0 are silently ignored: the binder fills in simple settings like ForwardLimit but leaves the trust lists at their loopback defaults, with no error. Read the values yourself with Configuration.GetSection(...).Get<string[]>() and add each one with IPAddress.Parse or, on .NET 10, System.Net.IPNetwork.Parse.
KnownIPNetworks is the .NET 10 replacement for KnownNetworks, using System.Net.IPNetwork instead of the older Microsoft.AspNetCore.HttpOverrides.IPNetwork. Building against the old API raises warning ASPDEPR005. KnownProxies is unaffected and is unchanged across versions.
Yes, by setting ForwardedForHeaderName = "X-Real-IP" and keeping the XForwardedFor flag. There is no separate flag for it. The RFC 7239 Forwarded header is not supported at all.
Set the log level for Microsoft.AspNetCore.HttpOverrides to Debug. The middleware then logs Unknown proxy: followed by the address and port of the connection it refused to trust, which is the address to add to KnownProxies.
The proxy forwards HTTP, so the request does not look like HTTPS. Enable XForwardedProto, make sure the proxy sends X-Forwarded-Proto and is in KnownProxies, and call UseForwardedHeaders() before authentication. Disabling the transport security requirement hides the error but leaves the scheme wrong.
Set ForwardedForHeaderName to the header the proxy actually sends, such as CF-Connecting-IP for Cloudflare or True-Client-IP on some appliances. Everything else, including the KnownProxies trust check, behaves the same. Only one header name can be read at a time.
X-Forwarded-For for IIS writes the real client address into the standard c-ip field, so SIEM, compliance and log-analysis tooling keep working unchanged, alongside the forwarded-headers middleware rather than instead of it. IIS 10 on Windows Server 2016–2025.