Search for "get client IP behind proxy" in almost any language and the top answer looks like this:
const ip = req.headers['x-forwarded-for']?.split(',')[0] || req.socket.remoteAddress;It works in testing and it works in production, right up to the moment someone sends their own X-Forwarded-For header. From then on they choose their IP address. Your rate limiter sees a new client on every request, your audit log records 127.0.0.1, an "internal only" admin check lets them in, and your geolocation puts them wherever they like.
This isn't hypothetical. Recent GitHub security advisories cover exactly this mistake in go-chi's RealIP middleware (published May 2026, fixed in chi v5.3.0), in Langflow, where a spoofed 127.0.0.1 passed a local-only check, and in Coder, where it enabled rate-limit bypass and audit-log falsification.
This article is about the risk: what a forged address buys an attacker, how the header is built, which entry you can trust and why, and how to test your own deployment. If you already know the risk and want the setting or snippet for your stack, go straight to the how-to guides for Django, Flask, FastAPI, Laravel, Rails and Go, Express and Fastify or Cloudflare.
What an attacker gets from a spoofed client IP
Anything that keys on the client address inherits the spoof. In practice:
| Control that uses the IP | What a forged X-Forwarded-For does to it |
|---|---|
| Rate limiting and brute-force protection | A new value per request makes every attempt look like a new client, so login and API limits never trigger |
| "Internal only" or admin allowlists | Sending 127.0.0.1 or an office address passes a check meant for local or trusted callers (the Langflow case above) |
| Audit logs and incident response | The log records whatever the attacker wrote, so the trail points at an innocent address or at your own server (the Coder case) |
| Geo-restrictions and country rules | The attacker picks the country your geolocation sees |
| Bot verification | A scraper sends an address from a crawler's published range and inherits that crawler's allowance |
| Log injection | Junk such as "><script> or newline characters reaches log viewers, databases and dashboards if the value isn't validated |
None of these needs a bug in your own code. One trusted-proxy setting that's too broad, or one copied split(',')[0], is enough.
How X-Forwarded-For is built
Each proxy that forwards a request appends the address it received the connection from. It does not replace the header, and it cannot check what was already there. Follow one request through a typical setup: a cloud load balancer, then Nginx on the app server, then your app.
- The client at
198.51.100.9sends the request with a header it made up:X-Forwarded-For: 1.2.3.4. - The load balancer at
10.0.0.5receives a connection from198.51.100.9and appends it:1.2.3.4, 198.51.100.9. - Nginx receives a connection from
10.0.0.5and appends that:1.2.3.4, 198.51.100.9, 10.0.0.5. - Your app receives a TCP connection from
127.0.0.1(Nginx) with that header.
| Position | Value | Written by | Can you trust it? |
|---|---|---|---|
| TCP peer | 127.0.0.1 | the operating system | Yes: nobody can fake the peer of an established TCP connection |
| last entry | 10.0.0.5 | Nginx, your proxy | Yes |
| second to last | 198.51.100.9 | the load balancer, your proxy | Yes: this is the client |
| first entry | 1.2.3.4 | the client | No: it's whatever they sent |
The first entry is only the client's address when the client didn't send the header. Anyone can make sure it isn't. The entries you can trust are the ones your proxies added, and they sit at the right-hand end of the list.
The trusted-proxy model: walk right to left, stop at the first address you don't trust
Here's the model that the Express docs, the patched chi middleware (upgrade to chi v5.3.0 or later if you use middleware.RealIP) and the best-known write-up on the subject all agree on:
- Start with the TCP peer address. If it isn't one of your proxies, that is the client. Ignore every forwarding header, because the client wrote them.
- Otherwise, read
X-Forwarded-Forfrom right to left. Each entry was written by the hop to its right, which you trusted. - Stop at the first entry that is not one of your proxies. That's the client.
- If an entry is malformed, stop and keep the last good address. A proxy you trust doesn't write garbage, so whatever is left of it came from the client.
There are two ways to say which addresses are "yours":
- A list of addresses or CIDR ranges (
127.0.0.1,10.0.0.0/8, your CDN's published ranges). This is the robust choice. A request that reaches your app directly, without a proxy, gets no say over its IP. - A hop count ("trust the last 2 entries"). This is simpler and works when every request passes through exactly that many proxies. If your app is also reachable directly, or through a shorter path, a client can supply the "trusted" entries itself. We verified this with Express 5: with
trust proxyset to1, a request sent straight to the app withX-Forwarded-For: 1.2.3.4gotreq.ip === '1.2.3.4'. With a list ('loopback'), the same request got its real address.
Use the list unless you can guarantee the app is only reachable through the proxy chain, for example because it listens on 127.0.0.1 or a private network.
A reference check in Node.js (no dependencies)
To make the model concrete, here it is as one function. node:net has everything needed: isIP to validate and BlockList to hold the trusted ranges.
import { BlockList, isIP } from 'node:net';
// The proxies you run (or your CDN's published ranges). Nothing else is trusted.
const trusted = new BlockList();
trusted.addAddress('127.0.0.1');
trusted.addAddress('::1', 'ipv6');
trusted.addSubnet('10.0.0.0', 8); // e.g. your load balancers' VPC
function normalize(ip) {
ip = String(ip).trim();
// Node reports IPv4 clients on a dual-stack socket as ::ffff:203.0.113.7
if (ip.toLowerCase().startsWith('::ffff:') && isIP(ip.slice(7)) === 4) ip = ip.slice(7);
return ip;
}
function isTrusted(ip) {
const family = isIP(ip);
return family !== 0 && trusted.check(ip, family === 6 ? 'ipv6' : 'ipv4');
}
/**
* remoteAddress: the TCP peer (req.socket.remoteAddress)
* xff: the X-Forwarded-For header value (Node joins repeated headers with ", ")
*/
export function clientIp(remoteAddress, xff = '') {
let ip = normalize(remoteAddress);
if (!isTrusted(ip)) return ip; // not from our proxy: ignore the header
const hops = xff.split(',').map(normalize).filter(Boolean);
for (let i = hops.length - 1; i >= 0; i--) {
if (isIP(hops[i]) === 0) break; // malformed entry: stop, keep the last good hop
ip = hops[i];
if (!isTrusted(ip)) return ip; // first address we did not add ourselves
}
return ip;
}Usage in any Node server:
const ip = clientIp(req.socket.remoteAddress, req.headers['x-forwarded-for']);Checked against the cases that matter:
clientIp('203.0.113.7', '1.2.3.4'); // '203.0.113.7' (direct, header ignored)
clientIp('127.0.0.1', '1.2.3.4, 198.51.100.9'); // '198.51.100.9' (spoofed first entry skipped)
clientIp('127.0.0.1', '6.6.6.6, 198.51.100.9, 10.0.0.5'); // '198.51.100.9' (two trusted hops)
clientIp('127.0.0.1', 'evil, 10.0.0.5'); // '10.0.0.5' (garbage stops the walk)
clientIp('::1', '2001:db8::1'); // '2001:db8::1'If you use Express, you don't need this function: app.set('trust proxy', ['loopback', '10.0.0.0/8']) runs the same algorithm (it's built on the proxy-addr package), and req.ip gives you the result. See the Express guide.
Other languages and frameworks
The same model applies everywhere: start from the TCP peer, trust only the proxies on your list, read from the right. What differs is where each framework lets you declare that list. The settings and tested code for each stack are in how to get the real client IP in Django, Flask, FastAPI, Laravel, Rails and Go:
- Django has no trusted-proxy setting, so you add a small middleware.
- Flask and other WSGI apps use Werkzeug's
ProxyFix, which is a hop count, with the weakness described above. - FastAPI on Uvicorn trusts only
--forwarded-allow-ips. - Laravel and Symfony use a
trustProxieslist. - Rails has
request.remote_ipand atrusted_proxieslist. - Go gives you only
r.RemoteAddr, so you write the walk yourself withnet/netip. - Express and Fastify have a
trust proxy/trustProxyoption that takes a list.
Edge cases that break naive parsers
- Repeated headers. A client can send
X-Forwarded-Fortwice. Node joins repeated values with", "in order. Go and Python WSGI servers differ. Always combine them in order before walking, or a client can hide entries in the "other" header. - IPv4-mapped IPv6. On a dual-stack socket the peer of an IPv4 client looks like
::ffff:203.0.113.7. Unmap it before comparing with IPv4 ranges, as the reference check does. - Ports. Some load balancers write
203.0.113.7:51234, and IPv6 with a port is[2001:db8::1]:51234. If yours does, strip the port innormalizeand still validate the result. - Whitespace and junk.
unknown, empty entries and spaces all appear in real traffic. Validate every entry with a real IP parser and never pass the raw string on to a database, log line or URL. X-Real-IPand CDN headers.X-Real-IPandCF-Connecting-IPhold a single address that your proxy or CDN sets (overwrites). They're safe exactly when the request provably came through that proxy. Trust them only if the TCP peer is in the proxy's range, or if the app isn't reachable any other way. See the Cloudflare guide.- The standard
Forwardedheader (RFC 7239) carries the same information in a structured form:Forwarded: for=198.51.100.9;proto=https, for="[2001:db8::17]:4711". The trust rules are identical. Most frameworks only readX-Forwarded-For, and a client can sendForwardedeven if your proxy doesn't. Ignore it unless your proxy writes it. - Several paths to the app. If some traffic reaches you through the CDN and some directly to the load balancer, a hop count is wrong for one of the paths. A trusted-range list handles both.
Close the side door
Everything above assumes the trust list matches reality. The commonest real-world bypass isn't a parser bug. It's an origin server that answers on its public IP, so the attacker skips the CDN and talks to Nginx directly.
- Bind app servers to
127.0.0.1, a Unix socket or a private network, so only the proxy can reach them. - If you're behind a CDN, firewall the origin to the CDN's published ranges, or use its authenticated origin feature.
- Re-check the trust list whenever the infrastructure changes. A new load balancer subnet that isn't listed makes every client look like the load balancer. A list that's too broad (
0.0.0.0/0, or "trust all" settings like Expresstrueand Laravel'*') brings back the spoofing.
Test it in one minute
Add a temporary debug route that echoes the address your code resolves, then:
# 1. Through the real entry point: should print YOUR public IP
curl https://example.com/debug-ip
# 2. Spoof attempt through the entry point: must still print your IP
curl -H "X-Forwarded-For: 1.2.3.4" https://example.com/debug-ip
# 3. Straight to the origin, bypassing the proxy: must NOT print 1.2.3.4
# (better still, it should time out or be refused)
curl -H "X-Forwarded-For: 1.2.3.4" http://ORIGIN_IP:3000/debug-ipIf request 2 or 3 prints 1.2.3.4, your trust configuration is too permissive. Remove the route when you're done.
When the first entry is fine
There's one legitimate use for the leftmost address: non-security analytics, where a forged value only hurts the forger. Even then, validate it as an IP and keep it separate from the trusted address. For anything that grants, limits, logs for audit or bills, use the trusted address.
What to do with the address next
Once you have an address you can trust, everything built on it becomes reliable: rate limiting by IP, allowlists and CIDR checks, bot verification and geolocation. To turn it into a country, city, time zone or ASN, it's one request:
curl "https://ipapi.gr/api/lookup/198.51.100.9?key=YOUR_API_KEY"Fields the data doesn't cover come back as the string "No Data", so check for it before you use a value. The country lookup guide has complete examples with timeouts and error handling.
FAQ
Is X-Real-IP safer than X-Forwarded-For?
Only because proxies usually overwrite it instead of appending. If the app is reachable without the proxy, a client can send X-Real-IP as easily as anything else. Same rule: trust it only from a known proxy.
Should I take the last entry instead of the first?
The last entry is the right answer when there is exactly one proxy and it's yours. With two proxies (a CDN and a load balancer, say), the last entry is your CDN's edge address. Walking right to left past trusted addresses handles any number of hops.
Which frameworks are safe by default?
Few are "safe" or "unsafe" on their own; it depends on the trust setting you give them. Uvicorn trusts only loopback by default, Rails trusts private ranges, Django trusts nothing, and Express, Fastify and Laravel trust nothing until you configure them. The per-stack settings are in the framework guide.
What about private addresses in the chain?
Don't skip private addresses automatically ("take the first public IP"). An attacker can send X-Forwarded-For: 10.1.2.3, and some real clients sit behind carrier-grade NAT. Trust is about who wrote the entry, which is your proxy list, not what the address looks like.