Running Behind a Proxy
Configure UseForwardedHeaders with the right trusted proxies so client IPs, HTTPS detection and redirects work behind load balancers and ingress.
builder.Services.Configure<ForwardedHeadersOptions>(o =>
{
o.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
o.KnownIPNetworks.Add(IPNetwork.Parse("10.244.0.0/16"));
});
app.UseForwardedHeaders();In production an ASP.NET Core app almost never talks to clients directly. A load balancer, ingress controller or CDN terminates TLS and opens a new HTTP connection to the app. Without extra work the app sees the proxy's IP as the client, http as the scheme and possibly an internal host name. Proxies pass the originals in headers: X-Forwarded-For (client IP chain), X-Forwarded-Proto (scheme) and X-Forwarded-Host.
What the middleware does
UseForwardedHeaders reads those headers and rewrites Connection.RemoteIpAddress, Request.Scheme and Request.Host, saving the originals as X-Original-For, X-Original-Proto and X-Original-Host. It only trusts headers that arrived from a known proxy: the immediate peer must be in KnownProxies or KnownIPNetworks. Otherwise it leaves the request untouched and says nothing, which is the most common reason "forwarded headers don't work".
| Option | Default | Notes |
|---|---|---|
ForwardedHeaders | None | Set XForwardedFor | XForwardedProto (and XForwardedHost only with AllowedHosts) |
KnownProxies | ::1 | Exact addresses |
KnownIPNetworks | 127.0.0.0/8 | CIDR ranges, such as a pod or VPC subnet |
ForwardLimit | 1 | How many entries to process from the right of the chain |
X-Forwarded-For is processed right to left, because the rightmost entry was added by the nearest proxy and the leftmost was supplied by whoever made the first request, possibly an attacker. With the default limit of 1 and a chain of 203.0.113.50, 10.1.2.3, the app takes 10.1.2.3. With a CDN in front of a load balancer, raise ForwardLimit to 2 and trust both hops.
Why HTTPS redirection loops
The load balancer receives https://shop.example.com/orders, terminates TLS and calls the app over HTTP. UseHttpsRedirection sees http and returns 307 to https://.... The browser follows, the load balancer forwards it as HTTP again, and the loop repeats until the browser gives up with too many redirects. HSTS, secure cookies, OpenID Connect redirect URIs and LinkGenerator.GetUriByName all break the same way. The fix is UseForwardedHeaders as the first middleware with the proxy trusted, so Request.IsHttps is true.
Choosing the configuration
- Kubernetes: ingress controller pods change IPs, so trust the pod CIDR (for example
10.244.0.0/16) withKnownIPNetworks. - Setting
ASPNETCORE_FORWARDEDHEADERS_ENABLED=trueturns onXForwardedForandXForwardedProtoand clears the known lists, so any peer is trusted. Use it only when the app is reachable exclusively through the proxy (no public IP, a network policy in place). - Clear the defaults and list real proxies when the app can be reached directly; otherwise a client sets
X-Forwarded-For: 1.2.3.4to dodge IP rate limits or poison audit logs.
Order matters: forwarded headers first, before the exception handler, HSTS, HTTPS redirection, authentication, rate limiting and request logging, because every one of them reads the scheme or client IP.
Example
When to use it
- Stop an infinite 307 loop after moving TLS termination to the cloud load balancer in front of the orders API.
- Record the real customer IP in fraud checks and rate-limit partitions instead of the ingress controller's pod IP.
- Trust X-Forwarded-For only from the 10.244.0.0/16 pod network so clients cannot spoof their address.
More examples
Kubernetes ingress with a CDN in front
Trusted networks come from configuration (for example <code>Proxy__TrustedNetworks__0=10.244.0.0/16</code> for the pod network and the CDN's published ranges), so operations can change them without a rebuild. Every hop that appends to <code>X-Forwarded-For</code> must be trusted and counted in <code>ForwardLimit</code>, or the app stops at the CDN's address.
using System.Net;
using Microsoft.AspNetCore.HttpOverrides;
var builder = WebApplication.CreateBuilder(args);
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
// CDN edge -> ingress controller -> pod: two hops appended entries to X-Forwarded-For.
options.ForwardLimit = 2;
options.KnownProxies.Clear();
options.KnownIPNetworks.Clear();
foreach (var cidr in builder.Configuration.GetSection("Proxy:TrustedNetworks").Get<string[]>() ?? [])
{
options.KnownIPNetworks.Add(System.Net.IPNetwork.Parse(cidr));
}
});
var app = builder.Build();
app.UseForwardedHeaders();
app.UseExceptionHandler();
app.UseHsts();
app.UseHttpsRedirection();
app.MapGet("/orders/{id:int}", (int id, HttpContext context) =>
TypedResults.Ok(new { id, clientIp = context.Connection.RemoteIpAddress?.ToString() }));
app.Run();Enabling it from the environment in a Deployment
<code>ASPNETCORE_FORWARDEDHEADERS_ENABLED</code> adds the forwarded headers middleware with both headers enabled and the known proxy lists cleared, with no code change. That is convenient for a pod that only the ingress can reach, and dangerous for anything exposed directly, where clients can forge their IP and scheme.
apiVersion: apps/v1
kind: Deployment
metadata:
name: orders-api
spec:
replicas: 3
selector:
matchLabels:
app: orders-api
template:
metadata:
labels:
app: orders-api
spec:
containers:
- name: api
image: registry.example.com/shop/orders-api:1.14.2
ports:
- containerPort: 8080
env:
# Trusts X-Forwarded-For/Proto from ANY peer: only safe because a
# NetworkPolicy allows traffic to this pod from the ingress namespace only.
- name: ASPNETCORE_FORWARDEDHEADERS_ENABLED
value: "true"Forwarding the host safely
Forwarding the host is only needed when the proxy rewrites <code>Host</code>. Absolute URLs built from the request (<code>GetUriByName</code>, <code>CreatedAtRoute</code> with a full URI, email links) then use the forwarded value, so <code>ForwardedHeadersOptions.AllowedHosts</code> must restrict it to your real domains. That list only filters <code>X-Forwarded-Host</code>: also set the top-level <code>AllowedHosts</code> setting (for example <code>shop.example.com;api.shop.example.com</code>; add <code>AddHostFiltering</code>/<code>UseHostFiltering</code> with the slim builder) so a forged <code>Host</code> is rejected, and build password-reset links from a configured public base URL.
using Microsoft.AspNetCore.HttpOverrides;
var builder = WebApplication.CreateBuilder(args);
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor
| ForwardedHeaders.XForwardedProto
| ForwardedHeaders.XForwardedHost;
// Without this list a client can send X-Forwarded-Host: evil.example and have the
// app build password-reset and Location links that point to the attacker's site.
options.AllowedHosts = ["shop.example.com", "api.shop.example.com"];
options.KnownIPNetworks.Add(System.Net.IPNetwork.Parse("10.0.0.0/8"));
});
var app = builder.Build();
app.UseForwardedHeaders();
app.MapGet("/orders/{id:int}", (int id) => TypedResults.Ok(new { id })).WithName("GetOrder");
app.MapGet("/orders/{id:int}/link", (int id, HttpContext context, LinkGenerator links) =>
TypedResults.Ok(new { self = links.GetUriByName(context, "GetOrder", new { id }) }));
app.Run();
Discussion