Forwarded Headers & Reverse-Proxy Hosting
Key Points
- When ASP.NET Core runs behind a reverse proxy (nginx, IIS, Azure Front Door, AGW, ALB, Traefik),
HttpContext.Connection.RemoteIpAddressshows the proxy, not the client.Request.Schemeishttp, nothttps. UseForwardedHeadersrestores client info fromX-Forwarded-For/-Proto/-Hostheaders — must run before authentication, redirection, and IP-based logging.- Trust boundary matters: don't blindly trust forwarded headers from the internet. Configure
KnownProxies/KnownNetworksto allow only proxies you control. - App Service / Functions / Container Apps — Microsoft's reverse-proxy environments emit forwarded headers and
ASPNETCORE_FORWARDEDHEADERS_ENABLED=truedoes the right thing automatically. - Without this: HTTPS redirect loops, broken auth (cookie marked secure but request looks insecure), wrong client IPs in audit logs and rate-limit policies.
Concepts (deep dive)
What forwarded headers carry
X-Forwarded-For: 1.2.3.4, 10.0.0.1 — original client + intermediate proxies
X-Forwarded-Proto: https — original scheme
X-Forwarded-Host: api.acme.com — original Host header
X-Forwarded-Port: 443 — original port (rare)
Forwarded: for=1.2.3.4;proto=https;host=... — RFC 7239 (less common)
The proxy adds these so the upstream app can act as if the client connected directly.
Why it matters
// Without UseForwardedHeaders:
ctx.Connection.RemoteIpAddress // 10.0.0.1 (proxy)
ctx.Request.Scheme // "http" (proxy → app over HTTP)
ctx.Request.IsHttps // false → HTTPS redirect loops
ctx.Request.Host // app's internal hostname
// With UseForwardedHeaders:
ctx.Connection.RemoteIpAddress // 1.2.3.4 (real client)
ctx.Request.Scheme // "https"
ctx.Request.IsHttps // true
ctx.Request.Host // "api.acme.com"
Configuration
builder.Services.Configure<ForwardedHeadersOptions>(opts =>
{
opts.ForwardedHeaders = ForwardedHeaders.XForwardedFor
| ForwardedHeaders.XForwardedProto
| ForwardedHeaders.XForwardedHost;
// Trust only your proxies
opts.KnownNetworks.Clear();
opts.KnownProxies.Clear();
opts.KnownNetworks.Add(new IPNetwork(IPAddress.Parse("10.0.0.0"), 8));
opts.KnownProxies.Add(IPAddress.Parse("172.16.0.1"));
// Optional: how many forwards deep to honor
opts.ForwardLimit = 2;
});
var app = builder.Build();
app.UseForwardedHeaders(); // FIRST — before auth, redirect, logging
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
Trust boundary
Without KnownProxies / KnownNetworks, anyone can spoof X-Forwarded-For and bypass IP-based rules. The defaults trust loopback only.
Public client ─► [Public LB] ─► [Internal proxy] ─► App
▲
trust this hop;
clear forwarded headers
that came from clients
Configure your proxy to strip any inbound X-Forwarded-* and write fresh values from the actual connection. Then ASP.NET Core trusts only what comes from the proxy IP range.
ForwardedHeaders.All is dangerous
opts.ForwardedHeaders = ForwardedHeaders.All; // includes XForwardedHost — host header injection risk
Set headers explicitly. Allow XForwardedHost only if your proxy enforces it (otherwise an attacker rewrites Request.Host and breaks Host-based routing or SSRF protections).
Order matters
✅ Correct middleware order:
UseForwardedHeaders ← first; rewrites Connection/Scheme/Host
UseHsts
UseHttpsRedirection ← now sees real Scheme
UseSerilogRequestLogging ← logs real client IP
UseRouting
UseAuthentication ← cookies marked Secure work over real HTTPS
UseAuthorization
UseEndpoints
If UseForwardedHeaders runs after UseAuthentication, login cookies are computed from the wrong scheme/host and may not be sent on subsequent requests.
App Service / Container Apps shortcut
For Microsoft-managed reverse proxies, set:
The runtime applies safe defaults (trusts the platform's proxy network). Manual UseForwardedHeaders not required.
IIS in-process vs out-of-process
- In-process (default): IIS hosts CLR; no proxy hop in your sense, but
HttpRequest.Schemereflects the W3SVC binding which may need patching. - Out-of-process: IIS reverse-proxies to Kestrel; standard forwarded-headers story applies.
UseIISIntegration() (called by WebApplication.CreateBuilder) wires this automatically.
Behind multiple proxies
X-Forwarded-For becomes a comma-list: 1.2.3.4, 10.0.0.5, 172.16.0.3. ASP.NET Core walks from rightmost back, accepting hops in KnownProxies until it hits the first untrusted address — that's the "real" client. Configure ForwardLimit to bound the walk.
YARP scenario
YARP (Microsoft.ReverseProxy) is itself ASP.NET Core. It writes X-Forwarded-For etc. when routing upstream. The upstream app uses the same UseForwardedHeaders configuration.
// In YARP cluster config:
{
"ClusterId": "backend",
"Destinations": { "d1": { "Address": "http://backend1:5000/" } },
"HttpRequest": { "ActivityTimeout": "00:01:00" },
"Metadata": { "ForwardedHeaders": "Append" }
}
Code: correct vs wrong
❌ Wrong: middleware order
✅ Correct
❌ Wrong: trusting all proxies
opts.ForwardedHeaders = ForwardedHeaders.All;
opts.KnownProxies.Add(IPAddress.Any); // anybody on the internet can spoof
✅ Correct: explicit trusted ranges
❌ Wrong: writing header logic by hand
Skips trust validation, ignores spoofing.
✅ Correct: rely on the middleware
Design patterns for this topic
Pattern 1 — Production-grade forwarded-headers config
builder.Services.Configure<ForwardedHeadersOptions>(opts =>
{
opts.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
opts.KnownNetworks.Clear();
opts.KnownProxies.Clear();
foreach (var range in builder.Configuration.GetSection("ProxyTrust:Networks").Get<string[]>() ?? [])
{
var parts = range.Split('/');
opts.KnownNetworks.Add(new IPNetwork(IPAddress.Parse(parts[0]), int.Parse(parts[1])));
}
opts.ForwardLimit = 2;
});
Pull trust boundaries from configuration; deploy-time pin.
Pattern 2 — Real-IP-aware rate limiter
builder.Services.AddRateLimiter(options =>
{
options.AddPolicy("per-ip", ctx =>
RateLimitPartition.GetFixedWindowLimiter(
partitionKey: ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown",
factory: _ => new FixedWindowRateLimiterOptions { Window = TimeSpan.FromMinutes(1), PermitLimit = 100 }));
});
After UseForwardedHeaders, RemoteIpAddress is the real client.
Pattern 3 — Defensive base middleware
app.Use(async (ctx, next) =>
{
if (ctx.Request.Scheme != "https" && !env.IsDevelopment())
ctx.Response.Headers["Strict-Transport-Security"] = "max-age=31536000";
await next();
});
Once Scheme is correct, enforce HSTS without redirect surprises.
Pattern 4 — Audit logger using real client IP
_logger.LogInformation("Request from {ClientIp} {Method} {Path}",
ctx.Connection.RemoteIpAddress, ctx.Request.Method, ctx.Request.Path);
Pros & cons / trade-offs
| Setup | Pros | Cons |
|---|---|---|
| Default (no proxy) | Simple | Wrong info behind proxy |
UseForwardedHeaders + trust list | Correct, secure | Must keep trust list current |
ASPNETCORE_FORWARDEDHEADERS_ENABLED=true | One-liner for App Service | Less control |
Hand-parsing X-Forwarded-For | Maximum control | Easy to misuse, security risk |
When to use / when to avoid
- Always when running behind nginx, IIS out-of-process, AGW, ALB, AFD, CloudFront, Container Apps, App Service.
- Skip when running plain Kestrel publicly (rare, single-instance dev).
- Avoid trusting
X-Forwarded-Hostunless your proxy enforces it.
Interview Q&A
Q1. Why does Request.IsHttps return false behind nginx? A. nginx terminates TLS and proxies to Kestrel over plaintext. Kestrel sees an HTTP connection. UseForwardedHeaders reads X-Forwarded-Proto: https and rewrites Request.Scheme to "https".
Q2. Why must UseForwardedHeaders run before UseAuthentication? A. Authentication issues cookies marked Secure and validates them based on Request.Scheme. If the scheme is wrong (still http), cookies aren't sent on subsequent HTTPS requests, breaking sign-in.
Q3. What's the security risk of ForwardedHeaders.All + IPAddress.Any trust? A. Any external attacker can spoof X-Forwarded-For to bypass IP-based rules, or X-Forwarded-Host to impersonate any host. Always explicitly list trusted networks.
Q4. How do you handle multiple proxy hops? A. Configure ForwardLimit (default 1). The middleware walks X-Forwarded-For from rightmost backward, accepting addresses in KnownProxies/KnownNetworks until it hits the first untrusted — that's the client.
Q5. What's ASPNETCORE_FORWARDEDHEADERS_ENABLED? A. Environment variable Microsoft platforms set so the runtime auto-applies safe forwarded-headers config without your code calling UseForwardedHeaders. Useful for App Service / Container Apps.
Q6. Difference between KnownProxies and KnownNetworks? A. KnownProxies lists single IPs; KnownNetworks lists CIDR ranges. Functionally equivalent; use whichever matches your trust definition.
Q7. What does YARP write? A. X-Forwarded-For, X-Forwarded-Proto, X-Forwarded-Host, X-Forwarded-Prefix — controllable per-cluster. Upstream app applies UseForwardedHeaders to consume them.
Q8. Why might HTTPS redirect loop? A. App receives http (proxy already terminated TLS), redirects to https, browser hits proxy with https, proxy forwards as http to app, app redirects again. Fix: UseForwardedHeaders so app sees https.
Gotchas / common mistakes
UseForwardedHeadersafterUseHttpsRedirection— redirect loops.UseForwardedHeadersafterUseAuthentication— cookies broken.KnownProxies.Clear()not called — defaults trust loopback only, your real proxy IP is rejected.- Trusting
X-Forwarded-Hostwithout proxy enforcement — host injection. - Reading
X-Forwarded-Fordirectly in user code instead of usingRemoteIpAddress. - Forgetting App Service env var — manual middleware works fine but
ASPNETCORE_FORWARDEDHEADERS_ENABLED=trueis simpler. - ALB / NLB writing different forwarded styles — verify which header your proxy emits.
- IIS in-process and adding
UseIISIntegrationmanually — already wired byCreateBuilder.