Why the platform needs the real client IP, where it actually lives in each Azure hosting topology, the options for reading it, and the approach QIIUB uses and why it holds up. Reference doc β read this to understand why client-IP handling is configured the way it is.
Created: 2026-07-11 | Last Updated: 2026-07-11
QIIUB records the caller's IP in several security-relevant places: session records (RefreshToken.IpAddress, shown on the user's sessions page), magic-link and handoff audit rows, SystemAuditLog, AI request logs, and the anonymous rate-limit bucket. If that value is the address of an infrastructure proxy instead of the real client, every one of those becomes useless (or worse, misleading in an incident). So "what is the real client IP, reliably?" is a real security question, not a cosmetic one.
The subtlety: the answer depends on what sits in front of the app. The same code returns different things on Linux vs Windows App Service vs behind a CDN/Front Door. This doc makes that explicit so the behavior isn't a surprise on a future infra change.
RemoteIpAddressHttpContext.Connection.RemoteIpAddress). If a proxy sits in front, this is the proxy's IP unless forwarded-headers processing rewrites it. The client cannot forge it β it is the real socket peer.X-Forwarded-ForX-Azure-ClientIP / X-Azure-SocketIPSocketIP = the TCP peer Front Door accepted (not client-forgeable β the most trustworthy signal). ClientIP = what Front Door believes is the client.KnownNetworks / KnownProxiesForwardLimit1 = one proxy in front (App Service alone). 2 = two (App Service behind Front Door).ASPNETCORE_FORWARDEDHEADERS_ENABLEDtrue, auto-wiring forwarded-headers processing with the trust list cleared and ForwardLimit=1. Convenient, but implicit and easy to lose on an infra change.Same app, three ways to host it. In each: what does RemoteIpAddress resolve to, and where is the true client IP?
The blessed Linux image sets ASPNETCORE_FORWARDEDHEADERS_ENABLED=true, so Kestrel reads the rightmost XFF entry the front-end set. Verified empirically in QA: session rows record the real client IP.
Different mechanism β IIS/ARR rewrites REMOTE_ADDR before .NET sees the request, and IIS integration auto-enables forwarded headers. A LinuxβWindows move keeps the real IP working.
Two proxy hops. With ForwardLimit=1, RemoteIpAddress resolves to Front Door's egress, not the client. Requires ForwardLimit=2 + FD ranges in KnownNetworks, or reading X-Azure-SocketIP.
X-Azure-SocketIP), but with a one-hop config the app reads the CDN's own IP. Fronting the app is a deliberate config change, never transparent β which is the whole reason to make the trust config explicit rather than implicit.| Option | What it is | Trade-off |
|---|---|---|
| Rely on the platform env-var | Let the Linux image's ASPNETCORE_FORWARDEDHEADERS_ENABLED=true do it implicitly. |
Works today with zero code β but invisible, Linux-only, trust-all, and silently lost on a Windows move or image change. Correctness you can't see in the repo. |
Explicit UseForwardedHeaders + known networks chosen |
Wire the middleware in code; trust only configured proxy ranges (KnownNetworks), hop count from config. |
The industry standard ("trust only your known edge"). Correct on Linux & Windows; a future CDN is a config change, not code. One visible source of truth for proxy trust. |
Read X-Azure-* headers directly |
Skip XFF hop-math; read X-Azure-SocketIP (Front-Door-authoritative). |
Best precision behind Front Door, but Front-Door-specific β doesn't help the no-CDN topology. A later add-on if/when Front Door lands. |
| Trust-all (clear the lists) | Accept XFF from any peer. | Rejected. If the app is ever reachable without a real proxy in front, a client can forge its IP. Flagged as insecure by the framework since .NET 8.0.17. |
QIIUB wires UseForwardedHeaders explicitly, first in the pipeline, trusting only configured proxy ranges:
ForwardedHeaders = XForwardedFor | XForwardedProto // not XForwardedHost
ForwardLimit = 1 // from config; 2 when behind Front Door
KnownNetworks = [ ... ] // from config: App Service internal range now; add FD range later
// KnownProxies/KnownNetworks are NEVER cleared (trust-all)
Why this holds up across every topology:
RemoteIpAddress for audit, sessions, and rate-limiting.KnownNetworks, set ForwardLimit=2 (or read X-Azure-SocketIP). No code ships at cutover.X-Forwarded-For is ignored β the recorded IP stays honest.KnownNetworks is not "safe default" β it's a no-op. Empty = trust nobody = XFF never applied, so if the platform env-var is also off, RemoteIpAddress becomes the internal proxy IP and every audit/session/rate-limit value degrades. The trust list must be populated with the real proxy range, not left empty.ASPNETCORE_FORWARDEDHEADERS_ENABLED=false so the explicit, visible config is the only one in effect.Once the real IP is trustworthy, it's used to bucket anonymous / pre-auth traffic (login, magic-link, refresh) in the global limiter. A common question: shouldn't a multi-tenant SaaS key rate limits on the tenant instead of IP? For most SaaS, yes β but QIIUB's traffic model is different:
Sync/Push (batched). The throttled surface is auth (pre-login) + AI + low-volume back-office web CRUD.So the limiter keys on the (now-trustworthy) client IP, and the edge WAF β if a CDN is ever added β stays the coarse volumetric layer on top. App-level and edge-level limiting are complementary layers, not either/or.
Related: docs/adr/0117-session-security-architecture.md Β§8 Β· docs/security/session-threat-model.md Β· docs/html/qiiub-session-token-security.html.