Rate limits, geo-blocking, IP allow-lists, fraud signals, and audit logs all lean on the same value - "the IP address of the client". Behind a CDN or load balancer that value almost never comes from the network connection. It comes from a header, and headers are written by whoever sends the request.
That makes the client IP untrusted input. Treating it as infrastructure metadata means every control built on top of it can be forged with a single curl flag.
Once traffic passes through a CDN, reverse proxy, or tunnel, the TCP connection your application sees originates from the proxy - not the user. To recover the real client IP, frameworks read forwarded headers instead: UseForwardedHeaders() in ASP.NET Core, trust proxy in Express, real_ip_header in NGINX.
The catch is that X-Forwarded-For is an ordinary request header. Anyone can set it.
curl https://api.example.com/orders \-H "X-Forwarded-For: 1.2.3.4"
❌ Figure: Bad example - One header, and every IP-based control now believes the caller is 1.2.3.4
ASP.NET Core ships a safe default here - it only honours forwarded headers from loopback. But that default breaks the moment the app runs in a container or behind a tunnel, and the well-worn workaround is to clear the restrictions entirely:
// Program.csvar options = new ForwardedHeadersOptions{ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto};options.KnownProxies.Clear();options.KnownNetworks.Clear();app.UseForwardedHeaders(options);
❌ Figure: Bad example - Clearing the known proxies makes the app trust X-Forwarded-For from anyone
After this, HttpContext.Connection.RemoteIpAddress reports whatever the caller asked it to report.
Getting this wrong is not only a security problem - it is an availability problem too:
The fix is to decide what the client IP is exactly once, at the first hop you actually control, and to make every backend inherit that decision. Patching each application means N services, N frameworks, and N chances to get it wrong - and the newest service is always the one that was missed.
Most CDNs already publish the true client IP in a header they set themselves and that callers cannot forge, because the edge overwrites it on the way through. Have the edge strip the spoofable header and rewrite it from that trusted signal:
export default {async fetch(request) {const clientIp = request.headers.get("CF-Connecting-IP");// Fail closed - a request that did not come through the edge is rejectedif (!clientIp) {return new Response("Forbidden", { status: 403 });}const headers = new Headers(request.headers);headers.delete("X-Forwarded-For"); // 1. drop whatever the caller sentheaders.set("X-Forwarded-For", clientIp); // 2. set it from the edge's own signalreturn fetch(new Request(request, { headers }));},};
✅ Figure: Good example - The edge guarantees the client IP, so downstream apps need no code changes
The header name differs per provider - see your CDN's documentation for the current one, for example Cloudflare's HTTP request headers. What matters is not the name but the guarantee: the value must be written by infrastructure you own, on a path callers cannot skip.
The order is load-bearing. X-Forwarded-For is a comma-separated list, and it may legitimately arrive as several repeated headers that get joined together. Appending to it - or setting it without deleting first - can leave the caller's entry sitting at the front of the list, and the leftmost entry is exactly the one most parsers report as the original client.
Delete, then set. That way there is only ever one value, and the edge wrote it.
If the trusted header is missing, the request did not come through the edge. Reject it.
The tempting alternative - fall back to the connection IP, or to whatever X-Forwarded-For was already there - reintroduces the vulnerability at precisely the moment someone finds a way around the edge: a direct-to-origin request, a stale DNS record pointing at the origin, or an internal tunnel that was never meant to be reachable. A 403 turns that into a visible failure instead of a silent downgrade.
Sometimes there is no edge worker to hook into. In that case, configure the forwarded-headers middleware explicitly - never leave the defaults, and never clear the restrictions:
// Program.csvar options = new ForwardedHeadersOptions{ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto,ForwardLimit = 1 // only the hop added by your own ingress};options.KnownNetworks.Add(new IPNetwork(IPAddress.Parse("10.0.0.0"), 8)); // your ingress range onlyapp.UseForwardedHeaders(options);
😐 Figure: OK example - Adding the trusted network (not clearing the defaults) keeps the built-in loopback restriction and works alongside it, but this configuration now has to be repeated and maintained in every service
Doing this per-application is N places to get right and keep right. Prefer the edge chokepoint, and treat in-app configuration as the fallback for services that cannot sit behind one.
Local development usually does not traverse the CDN or reverse proxy, so none of this logic runs on a developer machine. The first real exercise of the code path is production - which is a terrible place to discover it is misconfigured.
Make it a deliberate check after any change to edge routing, ingress, or hosting:
X-Forwarded-For through the edge is logged with the real client IP, not the forged one