Trusted proxies & client IP detection

Getting the real client IP right behind nginx, load balancers, and CDNs, and why mistakes break every IP-based rule.

4 min read · verified against [email protected]

Client IP detection has exactly two ways to be wrong, and both compromise every IP-based rule in the system - rate limits, bans, geo, allowlists.

Two failure modes you can't afford

You ban your own proxy. trusted_proxy_depth is too low, so the app sees the load balancer or CDN edge as the "client". Aggregated upstream traffic trips the rate limiter, the ban fires, every user behind that proxy goes dark.

An attacker spoofs the chain. trusted_proxies is too permissive, so X-Forwarded-For from anywhere is honored. The attacker sets X-Forwarded-For: 1.2.3.4 and bypasses every IP rule - country block, rate limit, ban list, allowlist.

Get this right once and it stays right for the lifetime of the topology.

Count the hops

For a topology like client → Cloudflare → nginx → your app, the depth is 2. Cloudflare appends one hop to X-Forwarded-For, nginx appends another. fastapi-guard walks back from the leftmost entry once it knows there are at least 2 trusted hops in the chain.

trusted_proxies - who can speak for the client

The list of IPs and ranges whose forwarded headers are believed. Anything originating outside this list: X-Forwarded-For is ignored entirely and the direct connection IP is used.

Accepts:

  • Bare IPv4: "127.0.0.1"
  • Bare IPv6: "::1"
  • IPv4 CIDR: "10.0.0.0/8"
  • Container CIDR: "172.16.0.0/12"

Mixed IPv4 / IPv6 entries and CIDR ranges can coexist in the same list.

trusted_proxy_depth - how far back to walk

N is the minimum chain length you expect.

  • Chain length ≥ depth → return the leftmost entry (the original client).
  • Chain length < depth → fall back to the direct connection IP. No error, no block - deterministic, fail-safe.
trusted_proxies=["127.0.0.1", "::1", "172.0.0.0/8"],
trusted_proxy_depth=2,

trust_x_forwarded_proto - scheme detection, scoped tight

A separate, narrower trust decision: do you believe the upstream proxy when it says the original request was HTTPS?

  • Does: honor X-Forwarded-Proto. When True, fastapi-guard treats this header as authoritative for the request scheme. If your LB terminates TLS, the backend sees plain HTTP. This flag is how enforce_https knows the original request was HTTPS.
  • Does NOT: touch X-Forwarded-Host. Host trust is a separate concern with separate semantics, and a separate attack surface. Enabling proto trust does not implicitly enable host trust.

Turn it on when TLS terminates upstream. Without it, enforce_https would reject every request as non-HTTPS.

IP resolution runs first - everything downstream uses it

Resolution happens once, at the front of the middleware chain. Every subsequent check sees the same value. There's no race, no double resolution, no inconsistency between rate-limit and ban.

  • Consistency: rate-limit and ban operate on the same IP a geo check just evaluated.
  • Performance: one resolution per request. Header parsing isn't repeated across checks.
  • Auditability: every event in the agent stream carries the same resolved IP; logs line up.

Three ways to get this wrong

trusted_proxy_depth=0 while behind a proxy - App treats the proxy IP as the client. Aggregated upstream traffic hits the rate limiter, ban triggers. You ban your own LB.

trusted_proxies=["0.0.0.0/0"] "to be safe" - Every source IP is treated as a trusted proxy. XFF from anywhere is honored. Attacker spoofs and bypasses every IP rule.

Add CDN, forget depth bump - Everything appears to work. Client IPs are now CDN edges. No errors, no alerts. Rate limits and bans hit edge IPs, affecting many real users.

Verify

Hit your app with a spoofed X-Forwarded-For from outside your trusted proxy range. If your config is right, fastapi-guard ignores it.

@app.get("/whoami")
async def whoami(request: Request):
    return {
        "resolved_ip": request.client.host,
        "raw_xff": request.headers.get("x-forwarded-for"),
    }
$ curl -H "X-Forwarded-For: 8.8.8.8" https://api.example.com/whoami
{"resolved_ip": "203.0.113.42", "raw_xff": "8.8.8.8"}

resolved_ip = your real IP → config is correct. resolved_ip = 8.8.8.8 → trusted_proxies is too permissive.

Troubleshooting

SymptomLikely causeFix
All requests look like they come from 10.0.0.1trusted_proxy_depth too low, or your proxy's IP isn't in trusted_proxiesConfirm proxy IP in trusted_proxies and bump depth to the real hop count
An attacker bypassed the country blocktrusted_proxies too permissive, or the attacker is hitting the app directlyTighten trusted_proxies to actual proxy IPs only. Firewall the app so the proxy is the sole path.
HTTPS-only routes return 403 over HTTPStrust_x_forwarded_proto=False while the LB terminates TLSSet trust_x_forwarded_proto=True. Verify the proxy sends X-Forwarded-Proto: https.