skip to content

An nginx config forwards `X-Forwarded-For $proxy_add_x_forwarded_for` and the backend reads the first address in that header to identify clients. A user forges the header and evades per-client limits. How do you make nginx produce a client address the backend can trust?

level: seniorimportance: should knowfreq 46%

answer

  1. clients can write the header too
  2. only hops you control are believable
  3. edge overwrites, inner hops append
  4. list the trusted ranges explicitly
  5. scan from the right, skip trusted

basics

~20 s

Stop letting the backend parse a client-supplied header. Use nginx's realip module: set_real_ip_from lists the proxy addresses you trust, real_ip_header names the header, and real_ip_recursive on makes $remote_addr the last address that did not come from a trusted hop.

solid answer

~50 s

X-Forwarded-For is appended to by every hop, including the client, so its leftmost entry is attacker-controlled unless something validates the chain. `$proxy_add_x_forwarded_for` deliberately preserves what the client sent, which is correct behaviour for a middle hop but disastrous if the backend treats entry one as gospel. Two fixes, used together. At the true edge — where the peer is the internet — overwrite instead of appending: `proxy_set_header X-Forwarded-For $remote_addr;`. Behind a CDN or another proxy, use `ngx_http_realip_module`: `set_real_ip_from` with the CIDRs of the hops you actually trust, `real_ip_header X-Forwarded-For;`, and `real_ip_recursive on;` so nginx walks the header from the right, skipping trusted addresses, and replaces `$remote_addr` with the last untrusted one. The original peer stays available in `$realip_remote_addr`. After that, everything keyed on `$remote_addr` — logs, `limit_req`, ACLs — is keyed on a validated address.

code

nginx · 9 lines
nginx
# nginx behind a CDN or outer load balancer
set_real_ip_from 192.0.2.0/24;
set_real_ip_from 198.51.100.0/24;
real_ip_header    X-Forwarded-For;
real_ip_recursive on;

log_format trusted '$remote_addr via $realip_remote_addr "$request" $status';

limit_req_zone $binary_remote_addr zone=perclient:10m rate=10r/s;

go deeper

for a junior

Understand that any client can send X-Forwarded-For itself, so the header is only meaningful if a proxy you control wrote it. Know that nginx has a module for turning it into $remote_addr.

for a middle

Explain the three realip directives and what each contributes: which peers are trusted, which header is read, and whether nginx walks the chain from the right. Know $realip_remote_addr keeps the original peer.

for a senior

Draw the trust boundary for a real topology, choose overwrite-at-edge versus realip-behind-CDN, and name the failure modes on both sides — trusting everyone, and a stale range list that collapses every client onto one key.

for a principal

Own client identity as a platform invariant: one place where the boundary is established, generated trust lists, origin access restricted to the front hop, and a decision on whether HTTP headers or the PROXY protocol carry the address across the fleet.

## Why the header cannot be trusted as written `X-Forwarded-For` is a list that each proxy appends to. A client is free to send one before the first proxy ever sees the request, and that forged value ends up leftmost: ``` X-Forwarded-For: 1.2.3.4, 203.0.113.9, 10.0.0.5 ^ forged ^ real client ^ inner proxy ``` `$proxy_add_x_forwarded_for` expands to the incoming header plus `$remote_addr`. That is exactly right for a middle hop — it preserves the chain — and exactly wrong as a source of truth. An application that reads the first element is reading whatever the client typed. The consequence is not academic: per-IP rate limits, IP allowlists, geo rules, abuse blocking and audit logs are all bypassed or poisoned by one header. ## Establish a trust boundary The only addresses you can believe are those written by hops you control. Everything to the left of the first hop you trust is unverified. **Case 1 — nginx is the edge.** The peer address *is* the client. Do not append; overwrite: ```nginx proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; ``` This destroys any forged chain at the door and gives the backend a single unambiguous value. **Case 2 — something you trust sits in front.** Now nginx's own peer is the CDN or outer load balancer, and the client address is inside the header. Use the realip module: ```nginx set_real_ip_from 10.0.0.0/8; set_real_ip_from 192.0.2.0/24; # the front proxy's egress range real_ip_header X-Forwarded-For; real_ip_recursive on; ``` ## What each directive does - **`set_real_ip_from`** declares a trusted sender: an address, a CIDR range, or `unix:` for a local socket. The replacement only happens when the actual peer matches one of these. An untrusted peer's header is ignored, which is the whole point. - **`real_ip_header`** names where to look. Besides `X-Forwarded-For` it accepts `X-Real-IP` and `proxy_protocol` — the last reads the address from a PROXY protocol header, which requires `listen ... proxy_protocol;` and is the sturdier option when the front hop is an L4 balancer that cannot add HTTP headers. - **`real_ip_recursive`** — off by default. Off, nginx takes the last address in the header. On, it scans from the right and takes the last address that is **not** in a trusted range. With a multi-hop chain, `on` is what you want; with `off` and two proxies in front you end up recording the inner proxy. After replacement, `$remote_addr` and `$binary_remote_addr` hold the recovered client address, so `limit_req_zone $binary_remote_addr`, `allow`/`deny`, access logs and any `geo` map all key on it automatically. The pre-replacement peer remains in `$realip_remote_addr`, which is worth logging — it tells you which front hop delivered each request. ## Getting the trust list wrong Two failure modes, opposite in shape: - **Too permissive.** `set_real_ip_from 0.0.0.0/0;` trusts everyone and reinstates the original vulnerability with more steps. So does trusting a shared provider range through which anyone can route traffic. - **Too narrow or stale.** A CDN adds egress ranges; the list goes out of date; suddenly the replacement stops happening and every client appears to come from the CDN. The tell is a rate limiter that starts throttling all traffic at once. Keep the ranges generated from the provider's published list rather than hand-maintained. Also remember that if the front hop can be bypassed — the origin is reachable directly on its public address — the trust boundary is fiction. Locking origin access to the front hop's ranges is part of the same control. ## Two headers, two questions It is worth separating them explicitly in an answer. *What the header means on the wire* is protocol territory. *Whether this deployment may believe it, and which hop establishes that* is a proxy-configuration decision, and it is the one an interviewer is probing. A candidate who says "we trust X-Forwarded-For because we set it" has not drawn the boundary; a candidate who names the trusted ranges, the direction of the scan, and what happens when the list is stale, has.

  • What difference does real_ip_recursive on make compared with the default?
    With it off, nginx takes the last address in the configured header. With it on, nginx scans the list from the right and takes the last address that is not inside any set_real_ip_from range. With two or more trusted hops in front, off leaves you recording the inner proxy; on recovers the actual client. The scan stops at the first untrusted entry, so forged values further left are ignored.
  • When would you prefer the PROXY protocol over X-Forwarded-For for the same job?
    When the front hop is an L4 balancer that forwards TCP without parsing HTTP, so it cannot add a header at all. Nginx reads the client address from the PROXY protocol preamble with `listen ... proxy_protocol;` and `real_ip_header proxy_protocol;`. It also works for non-HTTP traffic and cannot be forged by the client, because the preamble is written by the trusted hop before any client bytes.
  • What breaks if the trusted-range list goes stale after a CDN adds egress addresses?
    Requests arriving from the new ranges no longer match set_real_ip_from, so no replacement occurs and $remote_addr stays as the CDN node's address. Every client behind those nodes then shares one key: per-IP rate limits fire against everyone at once, allowlists misfire, and logs collapse to a handful of addresses. Generate the list from the provider's published ranges rather than maintaining it by hand.

saying these in an interview costs you the question

  • Says X-Forwarded-For is safe because nginx sets it
  • Reads the leftmost address as the client without validating the chain
  • Configures set_real_ip_from 0.0.0.0/0 to make it work
  • Leaves real_ip_recursive off with several proxies in front
  • Assumes the origin cannot be reached directly, bypassing the trusted hop

context