# X-Forwarded-For

> X-Forwarded-For carries the client IP through proxies and load balancers, but clients can forge it. Trust it safely in nginx, Express, Cloudflare and AWS.

Source: https://howhttpworks.com/headers/x-forwarded-for
Last reviewed: 2026-10-04

> **TL;DR:** `X-Forwarded-For` lists the client address followed by each proxy hop, but any client can forge it. Trust only the entries added by proxies you operate: configure the trusted proxies (nginx `set_real_ip_from`, Express `trust proxy`) instead of reading the leftmost value.

## What is X-Forwarded-For?

The **X-Forwarded-For** (XFF) header records the address a request came from before it reached your server, because the TCP connection your server sees belongs to the nearest proxy. Each proxy appends the address of the peer it received the request from.

```http
X-Forwarded-For: 203.0.113.195, 70.41.3.18, 150.172.238.178
```

Read left to right, that is: client `203.0.113.195`, which went through proxy `70.41.3.18`, whose address was seen by the last proxy `150.172.238.178`. The last proxy's own address is the socket peer and is not in the header. It is not standardised (MDN lists it as non-standard); [Forwarded](https://howhttpworks.com/headers/forwarded) is the RFC 7239 replacement.

## Why you cannot trust it by default

A proxy appends; it does not replace what the client sent. So a client that sends this directly:

```bash
curl -H 'X-Forwarded-For: 1.2.3.4' https://example.com/
```

reaches your app, behind one proxy, as `X-Forwarded-For: 1.2.3.4, <real client IP>`. The forged `1.2.3.4` is leftmost. Code that takes the first value now rate-limits, geo-blocks and audit-logs the wrong address, and an attacker can pick any IP to dodge a per-IP limit or to impersonate an allowlisted one.

Rule: start at the socket peer. If it is one of your proxies, step left one entry at a time while each entry is also one of your proxies. The first entry that is not yours is the client. Everything left of it is attacker-controlled text.

If no proxy sits in front of your origin, ignore the header entirely and use the socket address. If your origin is reachable directly (bypassing the CDN), every header is spoofable; lock the origin down to the proxy's IP ranges.

## Configure trusted proxies

**nginx** (`ngx_http_realip_module`, needs `--with-http_realip_module`, present in common distro builds). Without `real_ip_recursive on`, nginx takes the last address in the header; with it, nginx skips trusted addresses from the right:

```nginx
set_real_ip_from 10.0.0.0/8;        # your load balancer / proxy ranges
set_real_ip_from 192.168.0.0/16;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
```

`$remote_addr` then holds the client address and `$realip_remote_addr` keeps the original peer. Log both while you debug.

**Express.** Count hops or name subnets; never use `true` on an internet-facing app, because it trusts the leftmost value:

```javascript
// One reverse proxy (for example nginx or a single ALB) in front of Node
app.set('trust proxy', 1)

// Or name the proxy ranges
app.set('trust proxy', 'loopback, 10.0.0.0/8')

app.get('/ip', (req, res) => res.json({ ip: req.ip, chain: req.ips }))
```

With `trust proxy` unset, `req.ip` is the socket address, i.e. the proxy. The setting also controls `req.protocol` and `req.hostname`, so a wrong value affects secure-cookie and redirect behaviour as well.

**Cloudflare.** Cloudflare adds `CF-Connecting-IP` containing the single client address it saw, and appends to `X-Forwarded-For`. Use `CF-Connecting-IP`, but only if requests can reach you only through Cloudflare; otherwise a client can send that header too. Enterprise plans can also add `True-Client-IP`.

**AWS ALB.** The Application Load Balancer appends the connecting address to `X-Forwarded-For` by default, so the client-supplied part stays on the left. The ALB attribute `routing.http.xff_header_processing.mode` can be set to `append` (default), `preserve` or `remove`. CloudFront also appends the viewer IP, so behind CloudFront and an ALB there are two proxy hops to trust.

**Kubernetes ingress-nginx.** Controls live in the controller ConfigMap: `use-forwarded-headers` (trust inbound XFF, only when something you control is in front) and `proxy-real-ip-cidr` (which sources are trusted). With a cloud load balancer, `externalTrafficPolicy: Local` on the Service preserves the client source address.

**Django.** Django does not parse `X-Forwarded-For` for you: `REMOTE_ADDR` is the proxy. Either have the proxy layer rewrite it (nginx `realip`) or use a library that takes a trusted-proxy count, and never read `request.META['HTTP_X_FORWARDED_FOR']` raw.

## Logging and gotchas

- Log the full header plus the socket address. The socket address is the only value the client cannot forge.
- The header can contain IPv6 addresses, ports in some proxies (`1.2.3.4:5678`), and the string `unknown`. Parse and validate before using it as a key.
- Values are comma separated and the header may appear as several separate header lines; treat them as one comma-joined list.
- Do not use XFF alone for authorization. An IP allowlist that trusts a header you did not verify is a bypass.

## Related Headers

- [Forwarded](https://howhttpworks.com/headers/forwarded) - the standard alternative
- [X-Forwarded-Proto](https://howhttpworks.com/headers/x-forwarded-proto) - original scheme
- [Via](https://howhttpworks.com/headers/via) - proxies in the path
- [Host](https://howhttpworks.com/headers/host)
