Behind a reverse proxy, ASP.NET Core sees every request as plain HTTP from one address - the proxy's - until you tell it otherwise. The fix is one middleware, UseForwardedHeaders, configured to read X-Forwarded-For and X-Forwarded-Proto, registered before everything else, and told exactly which proxy it may believe. Get that right and the client IP, the scheme, HTTPS redirection, HSTS, secure cookies and OAuth callbacks all fix themselves. Get it wrong in the permissive direction and anyone can choose their own IP address by sending a header.
This post goes through the middleware option by option, then the things that sit next to it: redirect loops, HSTS, WebSockets and SignalR through a proxy, and the timeouts and limits on both sides.
What goes wrong behind a proxy#
When a proxy terminates TLS and forwards the request to Kestrel over plain HTTP, the connection Kestrel sees is from the proxy, and the original details survive only as headers the proxy added.
Without the middleware, these are all quietly wrong:
HttpContext.Connection.RemoteIpAddressis the proxy's address. Logs show one visitor; a rate limiter partitioned by IP puts every user in one bucket.HttpContext.Request.SchemeishttpandRequest.IsHttpsis false.UseHttpsRedirectionredirects requests that already arrived over HTTPS, and the browser loops until it reports too many redirects.UseHstsnever sends its header, because it only adds it to HTTPS responses.- Cookies with
CookieSecurePolicy.SameAsRequestare issued without theSecureflag. - Generated absolute URLs, including the
redirect_urisent to Google, Microsoft or GitHub during an external login, start withhttp://, and the provider rejects them as a mismatch.
Every one of those is a symptom of the same missing configuration. What a reverse proxy actually does covers the proxy side in general; this post is about what ASP.NET Core has to do about it.
The forwarded headers middleware#
The middleware is part of ASP.NET Core; no package is needed.
using Microsoft.AspNetCore.HttpOverrides;using System.Net;var builder = WebApplication.CreateBuilder(args);builder.Services.Configure<ForwardedHeadersOptions>(options =>{ options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; options.KnownProxies.Add(IPAddress.Parse("10.0.0.1")); // your proxy, see below});var app = builder.Build();app.UseForwardedHeaders(); // first, before anything that reads IP or schemeapp.UseHsts();app.UseHttpsRedirection();// authentication, rate limiting, endpoints...What it does for each request: if the connection comes from a trusted proxy, it reads the forwarded headers, replaces RemoteIpAddress and Scheme with the values in them, and moves the originals to X-Original-For and X-Original-Proto so you can still see them. If the connection does not come from a trusted proxy, it does nothing at all.
Order matters. Any middleware registered before UseForwardedHeaders sees the proxy's view of the request. Microsoft's guidance is to run it before everything else except diagnostics and error-handling middleware. Request logging in particular has to come after it, or your logs keep recording the proxy.
The options that matter:
| Option | Default | What it controls |
|---|---|---|
ForwardedHeaders | None | Which headers to process. Nothing happens until you set it |
ForwardLimit | 1 | How many entries to take from the right of X-Forwarded-For |
KnownProxies | loopback | Individual proxy addresses to trust |
KnownNetworks | 127.0.0.0/8 | Address ranges to trust |
AllowedHosts | empty | Hosts accepted from X-Forwarded-Host |
RequireHeaderSymmetry | false | Require the same number of values in each header |
ForwardedHeaders.XForwardedHost is deliberately missing from the example. Turning it on lets the proxy - or anyone who reaches the app directly - change Request.Host, which feeds into generated links and password-reset emails. Only enable it with AllowedHosts set to your real host names, and only if the proxy rewrites the Host header rather than passing it through. Most proxies pass the original Host through unchanged, in which case you do not need it.
KnownProxies: deciding who to believe#
X-Forwarded-For is a header, and a client can send any header it likes. The proxy appends the address it actually saw to whatever the client sent, so for a request from 198.51.100.7 that included a forged value, the app receives:
X-Forwarded-For: 6.6.6.6, 198.51.100.7The rightmost entry was written by your proxy and is true. Everything to its left was supplied by the client and is not. With ForwardLimit = 1 the middleware takes exactly one entry from the right - the true one - which is why the default limit is correct for a single proxy. If there are two proxies in a chain (a CDN in front of your proxy, say), the limit is 2 and both must be trusted.
The other half of the safety is KnownProxies. The middleware only processes headers on connections from an address it trusts, and by default that is loopback only. A proxy at any other address is ignored, so a freshly configured app often seems to do nothing: the headers arrive, the middleware sees an unknown source, and skips them silently.
To find out what address the proxy connects from, log it before the middleware runs:
app.Use(async (context, next) =>{ app.Logger.LogInformation("peer {Peer} xff {Xff}", context.Connection.RemoteIpAddress, context.Request.Headers["X-Forwarded-For"].ToString()); await next();});app.UseForwardedHeaders();Make one request through the domain, read the address from the log, add it to KnownProxies (or the range to KnownNetworks), and remove the logging. Check after a restart that the address is the same; if the proxy's source address can change, trust its network rather than one address.
In .NET 10, KnownNetworks is marked obsolete in favour of KnownIPNetworks, which takes the newer System.Net.IPNetwork type. The behaviour is the same; the old property still works and only produces a warning.
The environment variable shortcut, and its cost#
ASP.NET Core can turn the middleware on without code:
ASPNETCORE_FORWARDEDHEADERS_ENABLED=trueThis adds the middleware with XForwardedFor and XForwardedProto enabled - and it also clears KnownProxies and KnownNetworks, so it accepts the headers from any source. It was designed for platforms where the app is only reachable through the proxy, so there is nobody else to send them.
On a server where the app port is also reachable directly, that is the forged-IP problem from the warning above. It is fine for a quick test, and acceptable when the client IP is used for nothing more than logs. For anything that makes a decision on the client IP - rate limits, IP allow-lists, fraud scoring, login throttling - configure KnownProxies in code instead. Rate limits and abuse explains why a forgeable key is worse than no rate limit at all.
Here is what depends on it in a typical app. The built-in rate limiter partitions by whatever key you give it, and the usual key is the client address:
builder.Services.AddRateLimiter(options =>{ options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(ctx => RateLimitPartition.GetFixedWindowLimiter( ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown", _ => new FixedWindowRateLimiterOptions { PermitLimit = 100, Window = TimeSpan.FromMinutes(1) }));});With the middleware missing, that key is the proxy for every request, and the hundredth request in a minute from anyone blocks everyone. With the middleware trusting all sources, the key is whatever the attacker writes, and they get a fresh hundred requests by changing one header. Only the correctly scoped version gives you a limit per real client. UseRateLimiter must also come after UseForwardedHeaders in the pipeline, for the same reason logging must.
HTTPS redirection without a redirect loop#
UseHttpsRedirection answers HTTP requests with a 307 to the HTTPS URL. Behind a TLS-terminating proxy it has two failure modes.
The loop. Without X-Forwarded-Proto processed, every request looks like HTTP, so every request is redirected, including the ones that arrived over HTTPS. The forwarded headers configuration above fixes this.
The silent no-op. The middleware has to know which port to redirect to. It looks at HttpsRedirectionOptions.HttpsPort, then the https_port setting (ASPNETCORE_HTTPS_PORT in the environment), then the HTTPS endpoints Kestrel is listening on. Behind a proxy, Kestrel has no HTTPS endpoint, so the middleware logs "Failed to determine the https port for redirect" and does nothing. If you want the app to do the redirect, set the port the browser should use:
ASPNETCORE_HTTPS_PORT=443Before you do, check whether the proxy already redirects plain HTTP to HTTPS for your domain. If it does, the app's redirect never fires and you can leave the middleware out. Two layers redirecting is harmless; two layers that disagree about the scheme is how the loop starts. On RE:NODE, the app plan's proxy slot holds the certificate for your domain - point an A record at the address shown and it is issued and renewed automatically inside a 21-day window - and the client address arrives in X-Forwarded-For. Your domain and its certificate covers the DNS side.
HSTS#
HSTS tells the browser to use HTTPS for your host for a period, without asking. In ASP.NET Core:
builder.Services.AddHsts(options =>{ options.MaxAge = TimeSpan.FromDays(180); options.IncludeSubDomains = false; options.Preload = false;});if (!app.Environment.IsDevelopment()){ app.UseHsts();}The default MaxAge is 30 days. The middleware only adds the header to HTTPS responses, and it never adds it for localhost, 127.0.0.1 or [::1], which is why testing locally shows nothing.
Be careful with two settings. IncludeSubDomains applies the policy to every subdomain, including the one running an old admin tool on plain HTTP that you forgot about. Preload asks to be included in the list browsers ship with, which is very hard to undo. Start with a short MaxAge, confirm that every part of the site works over HTTPS, then lengthen it. If the proxy already sends Strict-Transport-Security, do not send it twice with different values.
WebSockets and SignalR through the proxy#
A WebSocket starts as an HTTP request with Upgrade: websocket and Connection: Upgrade headers, and the proxy has to pass both through and then hold the connection open. Most current proxies do this for HTTP/1.1. On the app side:
app.UseWebSockets(new WebSocketOptions{ KeepAliveInterval = TimeSpan.FromSeconds(30)});The default KeepAliveInterval is two minutes. A proxy that closes idle connections after 60 seconds will cut a quiet WebSocket long before the app sends its first ping, and the client sees an unexplained disconnect every minute. Keep the interval shorter than the shortest idle timeout between the browser and Kestrel.
SignalR handles this for you to a degree: the server sends a keep-alive every 15 seconds by default (KeepAliveInterval), and considers a client gone after 30 seconds without hearing from it (ClientTimeoutInterval). If WebSockets fail through the proxy, SignalR falls back to Server-Sent Events or long polling, which work but cost more requests and more latency. Check the browser's network tab: a connection that negotiates and then switches to long polling is usually a proxy that did not pass the upgrade. WebSockets behind a reverse proxy has the proxy-side detail, and SignalR real-time apps covers the hub configuration.
Timeouts, body sizes and keep-alive#
The proxy and Kestrel each have limits, and a request has to fit inside both.
| Kestrel setting | Default | Notes |
|---|---|---|
Limits.KeepAliveTimeout | 130 seconds | Idle time before Kestrel closes a connection |
Limits.RequestHeadersTimeout | 30 seconds | Time allowed to receive headers |
Limits.MaxRequestBodySize | 30,000,000 bytes | About 28.6 MB; override per endpoint |
Limits.MaxConcurrentConnections | unlimited | Rarely worth setting behind a proxy |
Kestrel's two-minute keep-alive is longer than the idle timeout of most proxies, which is the safe way round: the proxy closes idle connections first, so it never sends a request down a connection Kestrel is about to drop. That race is the classic cause of occasional 502s with nothing in the application log, and it is less common with Kestrel than with Node precisely because of this default. If you lowered KeepAliveTimeout, put it back.
For uploads, the smaller of the two body limits wins. Raise Kestrel's limit for the one endpoint that needs it with [RequestSizeLimit] on a controller action, or through IHttpMaxRequestBodySizeFeature at the start of the request, rather than globally; a large global limit is an easy way to let one client fill your memory. A request that is slower than the proxy's read timeout - a long report, a big export - should become a background job that the client polls, not a two-minute HTTP request. .NET worker services and background jobs shows the pattern.
Checking that it works#
Add a temporary diagnostic endpoint, call it once through the domain, and remove it:
app.MapGet("/debug/request", (HttpContext ctx) => new{ ip = ctx.Connection.RemoteIpAddress?.ToString(), scheme = ctx.Request.Scheme, host = ctx.Request.Host.Value, originalFor = ctx.Request.Headers["X-Original-For"].ToString()});Through the domain, ip should be your own public address, scheme should be https, and originalFor should show the proxy. Then call the app directly on its IP and port with a forged header:
$ curl -H "X-Forwarded-For: 1.2.3.4" http://203.0.113.10:25571/debug/requestThe response must not say 1.2.3.4. If it does, you are trusting sources you should not. On RE:NODE the app's port is allocated on the server's address, so this direct test is a real path an attacker could use, not a hypothetical one.
The last check is a response header: Strict-Transport-Security should appear on HTTPS responses through the domain if you enabled HSTS. If none of this works and the log shows nothing, go back to the logging middleware above and look at the peer address; nine times out of ten it is not in KnownProxies.
FAQ#
Why does UseForwardedHeaders seem to do nothing?
Either ForwardedHeaders is still None, or the proxy's address is not in KnownProxies or KnownNetworks. The middleware ignores headers from untrusted sources without logging anything at the default level, so log the peer address and compare.
Should I set ForwardLimit to null to read the whole header?
No. Taking more entries than you have proxies means trusting values the client wrote. Set the limit to the number of proxies you run, which for a single proxy slot is the default of 1.
Do I still need UseHttpsRedirection if the proxy redirects?
Not if the proxy already redirects every HTTP request for your domain. It is harmless to keep, provided X-Forwarded-Proto is processed so the app agrees with the proxy about which requests are already HTTPS.
Why do external logins fail with a redirect URI mismatch?
The app built the callback URL from Request.Scheme, which was http because the forwarded proto header was not processed. Fix the forwarded headers and the generated URL becomes https://, matching what you registered with the provider.
Does the proxy change how I configure Kestrel's ports?
Kestrel listens on plain HTTP on the allocated port, on all interfaces, with no certificate. Deploy an ASP.NET Core app from GitHub has the ASPNETCORE_URLS setup.




Comments
Completely anonymous: no account, no email, no cookie. We store the name you type, the text and the time - nothing else. Links are limited and markup is not rendered.