← Back to Wiki
Networking / Security

Behind a CDN, Every Client-IP Check You Write Is Wrong Until You Declare the Proxy

You write an allowlist. It matches nobody. You widen it, and it still matches nobody, because behind a CDN the connecting address is always the CDN. Then you switch to reading the forwarded header instead, it works immediately, and you have quietly created an authentication bypass that anyone can use by sending one header.

Share on X

What your server actually sees

With a CDN in front, the TCP connection reaching your origin comes from the edge. Your visitor's address is not in the packet at all. It is in a header the CDN added:

So client_ip 203.0.113.5 can never match a real user, and every request looks like it came from a handful of edge addresses.

The trap that catches homelabs specifically

BE WARNED: allowlisting your own private ranges locks out your own LAN. This one is genuinely surprising. Without split-horizon DNS, a browser sitting on your LAN still resolves your public hostname to the CDN. The request leaves your house, reaches the edge, and returns to your origin reporting your public egress address. It is never a 192.168.x address, so a client_ip 192.168.0.0/16 matcher rejects your laptop from ten feet away. The load-bearing entry is your public egress IP. Keep the private ranges only for the direct-to-origin path, hitting the box by LAN IP with a Host header.

Declare the proxy, then read the header

Reading a forwarded header is correct, and only if you have told the server whose word to take for it. In Caddy that is a global block naming the CDN's ranges:

{
	servers {
		trusted_proxies static 173.245.48.0/20 103.21.244.0/22 ...
	}
}

Now client_ip resolves to the real visitor for connections arriving from those ranges, and your matcher works. nginx spells it set_real_ip_from plus real_ip_header, and Traefik has a forwardedHeaders.trustedIPs list. Same idea everywhere.

Pull the ranges from the provider rather than pasting a list from a blog post, because they change:

curl -s https://www.cloudflare.com/ips-v4
curl -s https://www.cloudflare.com/ips-v6

The bypass you create by skipping that step

This is the part worth being blunt about. If your config reads CF-Connecting-IP without declaring who is allowed to set it, then anyone who can reach your origin directly can claim to be any address they like:

# against an origin that trusts the header unconditionally
curl -H "CF-Connecting-IP: 192.168.1.50" https://origin-ip/admin
curl -H "X-Forwarded-For: 203.0.113.5"   https://origin-ip/admin

Your allowlist is now a suggestion. Declaring trusted proxies fixes this in the same stroke as making the matcher work: a request that did not arrive from a trusted range has its forwarded headers ignored, so a forged header falls through to whatever your closed branch is.

Verify it from outside, with both a forged private address and a forged copy of your real egress IP. Both must be refused.

Finding your origin is easier than you think

The bypass above needs the attacker to reach your origin directly, which people assume is hard. It often is not. Historical DNS records, certificate transparency logs, an unproxied subdomain, or a mail server on the same address will all give it away.

So treat the CDN as a performance and filtering layer, not as an access control. The origin should refuse anything that did not come through the CDN:

# only accept connections from the CDN's ranges at the firewall
for r in $(curl -s https://www.cloudflare.com/ips-v4); do
  ufw allow from "$r" to any port 443 proto tcp
done
ufw deny 443/tcp

Add your own management path before you enable that, and see restricting access by source IP for how that goes wrong.

Two smaller things that bite

Your egress IP is probably not static

Once an allowlist depends on your home address, it joins the pile of things that break the day your ISP renumbers you, with symptoms that look unrelated to each other. Reconcile it from the live value rather than editing it by hand. See one source of truth for a dynamic home IP.

When this isn't your problem