The moment you put a proxy in front of customer domains, your origin stops seeing the visitor's IP address and starts seeing yours. For anything that does rate limiting, fraud scoring, geo rules, or audit logging, that is a correctness bug that silently corrupts data. Here is why it happens, and how to forward the real client IP without letting anyone spoof it.
Visitor (203.0.113.7)
│ TCP connection A
▼
Proxy / edge (198.51.100.2)
│ TCP connection B <-- origin's socket peer is 198.51.100.2, not the visitor
▼
Origin -> REMOTE_ADDR = 198.51.100.2 (wrong!)Why the origin sees the wrong IP
A proxy does not forward packets; it terminates one TCP connection and opens a new one to the origin. So the origin's kernel-level peer address is the proxy. Unless the visitor's address is carried at a higher layer, it is simply gone by the time your app reads REMOTE_ADDR.
Option 1 — X-Forwarded-For
The proxy adds the visitor's IP to an HTTP header. Each hop appends, so the header is a list, left to right, oldest to newest:
X-Forwarded-For: 203.0.113.7, 198.51.100.2The leftmost entry is the original client — but only if you trust the chain. This is universal for HTTP/HTTPS and what most web frameworks consume.
Option 2 — PROXY protocol
PROXY protocol prepends the real connection info at the TCP layer, before any HTTP is parsed. Use it for non-HTTP traffic, or when you want the origin to know the client IP without relying on HTTP headers. It requires both proxy and origin to speak it. For a typical web app, a correctly-trusted X-Forwarded-For is simpler and enough.
The spoofing trap (this is the important part)
X-Forwarded-For is just a header. Any client can send one:
curl https://app.acme.com -H "X-Forwarded-For: 1.2.3.4"If your app trusts that unconditionally, an attacker sets their IP to whatever they want — defeating rate limits, IP allow-lists, and geo restrictions. The rule: only trust forwarded headers when the connection originates from your own edge ranges, and strip/replace any client-supplied value at the trust boundary. Trust a specific number of hops or specific source IPs, never “always.”
Framework configuration
Every framework has an opt-in for exactly this — configure it, do not hand-roll it:
- Express/Node:
app.set('trust proxy', 1)(or your edge subnet). See the Node/Express guide. - Rails:
config.action_dispatch.trusted_proxiessorequest.remote_ipskips your edge. See the Rails guide. - Laravel: the
TrustProxiesmiddleware scoped to your edge IPs. See the Laravel guide. - Django: read the leftmost trusted
X-Forwarded-Forentry; do not naively useREMOTE_ADDR. See the Django guide.
How cnames.dev handles it
Our edge forwards the client address on every proxied request and signs its own hop, so your origin can trust it by verifying the request came from our ranges. Your app keeps its normal trust-proxy configuration and reads the real visitor IP — no corrupted analytics, no spoofable rate limits. This is stage six of the proxy anatomy; the full design is in the complete guide.