Wiki πŸ” Search

How QIIUB determines the client IP

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

Contents: Why it matters Β· Glossary Β· Where the real IP lives Β· The options Β· What QIIUB uses & why Β· Two pitfalls Β· Rate-limit key Β· Sources

Why the client IP matters here

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.

Glossary (plain English)

RemoteIpAddress
The IP of the TCP connection ASP.NET Core actually sees (HttpContext.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.
XFF β€” X-Forwarded-For
The standard header where a proxy writes the original client IP (a comma-chain, one hop per proxy). If no trusted proxy stripped/set it, a client can send a fake one β€” so XFF is only trustworthy when a known proxy set it.
X-Azure-ClientIP / X-Azure-SocketIP
Front-Door-specific headers. SocketIP = the TCP peer Front Door accepted (not client-forgeable β€” the most trustworthy signal). ClientIP = what Front Door believes is the client.
Trusted proxy β€” KnownNetworks / KnownProxies
The allowlist of proxy IP ranges you trust to have set XFF. ASP.NET Core (since .NET 8.0.17 / 9.0.6) ignores XFF from anything not on this list. Empty list = trust nobody.
ForwardLimit
How many proxy hops to walk back in the XFF chain. 1 = one proxy in front (App Service alone). 2 = two (App Service behind Front Door).
ASPNETCORE_FORWARDEDHEADERS_ENABLED
An env var the Azure Linux .NET base image sets to true, auto-wiring forwarded-headers processing with the trust list cleared and ForwardLimit=1. Convenient, but implicit and easy to lose on an infra change.

Where the real IP lives, per topology

Same app, three ways to host it. In each: what does RemoteIpAddress resolve to, and where is the true client IP?

1 Β· Linux App Service QIIUB today

Client real IP ARR front-end sets XFF Kestrel (.NET) platform env-var RemoteIpAddress = real 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.

2 Β· Windows App Service alternative

Client real IP IIS + ARR rewrites REMOTE_ADDR Kestrel (.NET) IIS integration mw RemoteIpAddress = 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.

3 Β· Behind Front Door / CDN needs config

Client real IP Front Door hop 2 App Service FE hop 1 Kestrel (.NET) ForwardLimit=1 RemoteIpAddress = Front Door IP βœ— real IP is in XFF / X-Azure-SocketIP

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.

A CDN/Front Door does not "just work". It provides the client IP (in XFF / 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.

The options for reading the client IP

OptionWhat it isTrade-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.

What QIIUB uses & why it works

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:

The principle in one line: trust only your known edge, and read the header it sets. Everything else (which header, how many hops) is configuration, kept in one place.

Two pitfalls the config guards against

Empty 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.
Don't run two forwarded-headers passes. The explicit middleware and the platform env-var must not both be active, or the second pass consumes an attacker-controlled XFF entry. QIIUB pins ASPNETCORE_FORWARDEDHEADERS_ENABLED=false so the explicit, visible config is the only one in effect.

The rate-limit key β€” and why IP is right here

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:

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.

Status: the Linux behavior above is verified empirically in QA; the explicit-wiring approach is the decided design (tracked for implementation in issue #702, part of the session-security epic, ADR-0117 Β§8). This page explains the why; the issue tracks the rollout.

Sources

Related: docs/adr/0117-session-security-architecture.md Β§8 Β· docs/security/session-threat-model.md Β· docs/html/qiiub-session-token-security.html.