Trusting a forwarding header without proving who wrote it is an authorisation decision
Almost every web application behind a load balancer needs to know the client's address. It is used for rate limiting, for access rules, for audit records, for geo restrictions, and increasingly for deciding which internal endpoints may be reached without a login. The address is not in the TCP connection any more, because the connection terminates at the proxy. So the application reads a header.
The header is the problem. X-Forwarded-For, Forwarded, X-Real-IP and the various CDN-specific equivalents are ordinary request headers. Unless something strips them at the edge, a client can send them, and the application has no way to tell the header it wrote from the header an attacker wrote.
The header accumulates, and the application usually takes the wrong element
X-Forwarded-For is defined informally as a comma-separated list, and each proxy appends the address it received the connection from. A single request through two proxies therefore arrives with three values. Frameworks differ on which element they read: the first, the last, the leftmost untrusted, or a client-supplied value replaced by the edge.
When the application takes the first element and the edge appends rather than replaces, the client controls the value it will be judged by. The request below reaches the application with 1.2.3.4 in the first position regardless of where it actually came from:
GET /admin HTTP/1.1
Host: app.example.com
X-Forwarded-For: 1.2.3.4
The consequence is proportional to what the application does with that address. If it is only used for a dashboard, the impact is cosmetic. If an access rule permits an internal range, or if the address is written into an audit line that incident responders later trust, the impact is an authorisation or integrity failure.
The fix belongs at the edge, and it is a strip, not a check
The reliable pattern is that the outermost trusted component removes inbound forwarding headers and then writes its own. Everything downstream may then trust exactly one hop. Expressing that in a reverse proxy is a single directive:
proxy_set_header X-Forwarded-For $remote_addr;
$remote_addr is the address of the peer the proxy actually accepted, and assigning it replaces whatever the client sent. Note the difference from the common $proxy_add_x_forwarded_for, which appends the peer to the incoming value and preserves client-supplied content in the leftmost position.
Framework configuration mirrors the same idea. Most web frameworks have a setting that names how many proxies are trusted, and the setting is usually wrong in both directions: too permissive in development, too narrow in production after a CDN was added. The value should match the number of hops the deployment actually has, and it should be reviewed when the edge changes.
Application-layer trust is a separate deployment
Some architectures need the client address for decisions that matter, and stripping headers is not sufficient because the application also accepts traffic from another internal path. In that case the address must be carried in a way the client cannot forge, such as a signed header, a mTLS peer identity, or a proxy protocol that operates below HTTP.
The proxy protocol is worth distinguishing. It carries the original address in the transport handshake rather than in an HTTP header, so a client cannot inject it through a request. It has its own deployment cost: every component in the path must understand it, and an unconfigured listener that receives a proxy protocol preamble will treat the bytes as a malformed request. That failure is loud, which is preferable to the silent header case.
What to check in an existing deployment
- Send a request with
X-Forwarded-For: 127.0.0.1from outside and confirm the application does not treat it as local. - Confirm the edge replaces rather than appends the header, including on the error and redirect paths.
- Check whether the application's trusted-proxy setting still matches the current number of hops.
- Look for logic that grants privileges based on the client address, and treat each instance as an authorisation control requiring its own review.
- Log both the peer address and the resolved client address, so the two can be compared after an incident.
The third item is the one that decays silently. CDNs get added, ingress controllers get replaced, and a setting that was correct in March grants trust to a hop that no longer exists in October. Because the failure mode is a permissive one, nothing breaks, and nothing alerts. The only way to find it is to test the header from the outside and read what the application decided.
References
- RFC 7239, Forwarded HTTP Extension: https://www.rfc-editor.org/rfc/rfc7239.html
- nginx documentation,
proxy_set_headerand the$remote_addrvariable: https://nginx.org/en/docs/http/ngx_http_proxy_module.html - HAProxy PROXY protocol specification: https://www.haproxy.org/download/2.9/doc/proxy-protocol.txt
Top comments (0)