Configuring trusted proxies
Correct trusted-proxy config for nginx, HAProxy, Apache, Envoy, Traefik, Caddy, Express, Django, Rails and Spring Boot, plus a curl test that proves it.
Key points
- A trusted proxy list defines the boundary where attacker-controlled
X-Forwarded-Forentries end and infrastructure-controlled entries begin; everything left of that boundary is untrusted input. - There are only two trust models in use: a CIDR allowlist (nginx, Apache, Traefik, Caddy, Rails) and a fixed hop count (Envoy
xff_num_trusted_hops, Express numerictrust proxy). real_ip_recursive off(the nginx default) takes the last XFF entry, which is wrong as soon as you have two appending proxies in the chain.- Spring Boot's
frameworkstrategy and Traefik'sforwardedHeaders.insecureapply forwarded headers with no source check at all.
Configuring trusted proxies means telling each component in your stack which peer addresses are allowed to assert a client IP on someone else's behalf. Every implementation does the same three things: check whether the TCP peer is in the trusted set, walk the X-Forwarded-For list from the right discarding entries contributed by trusted hops, and take the first entry that a trusted hop did not produce. The differences are in the syntax, in the default, and in whether trust is expressed as a CIDR allowlist or as a hop count.
The one-screen answer#
| Stack | Setting | Trust model | Default | |
|---|---|---|---|---|
| nginx | set_real_ip_from CIDR, real_ip_header, real_ip_recursive | CIDR allowlist | No trusted sources; real_ip_header X-Real-IP; real_ip_recursive off | |
| HAProxy | http-request set-src hdr_ip(x-forwarded-for,-1) if { src -f trusted.lst } | CIDR allowlist, written by hand as an ACL | No implicit trust; src is the socket peer | |
| Apache httpd | RemoteIPHeader, RemoteIPInternalProxy, RemoteIPTrustedProxy | CIDR allowlist, two tiers | Module inactive until RemoteIPHeader is set | |
| Envoy | use_remote_address, xff_num_trusted_hops | Hop count (CIDR available in newer XFF detection extension) | use_remote_address: false, xff_num_trusted_hops: 0 | |
| Traefik | entryPoints.NAME.forwardedHeaders.trustedIPs or .insecure | CIDR allowlist, or trust-everything switch | Empty list: incoming X-Forwarded-* from clients is overwritten | |
| Caddy | servers { trusted_proxies static RANGES }, client_ip_headers | CIDR allowlist | No trusted proxies; {client_ip} equals {remote_host} | |
| Express | app.set('trust proxy', ...) | CIDR allowlist, hop count, or predicate function | false | |
| Django | SECURE_PROXY_SSL_HEADER, USE_X_FORWARDED_HOST | None shipped for client IP | None and False; REMOTE_ADDR stays the socket peer | |
| Rails | config.action_dispatch.trusted_proxies | CIDR allowlist that replaces the built-in private ranges | Loopback, 10/8, 172.16/12, 192.168/16, 169.254/16, ::1, fc00::/7, fe80::/10 | |
| Spring Boot | `server.forward-headers-strategy=native\ | framework` | native: CIDR regex in the container. framework: none | none, except on detected cloud platforms where it is native |
Two rows deserve a flag before you read any further. Rails trusts every RFC 1918 address until you configure trusted_proxies, so an unconfigured Rails app behind a proxy on a public address trusts all of private address space whether you wanted that or not. And Spring Boot's framework strategy performs no source check, it simply applies Forwarded and X-Forwarded-* from whoever sent them, which is safe only if a proxy in front is guaranteed to overwrite those headers.
What the trust boundary actually is#
X-Forwarded-For is an append-only list. Each proxy that honours the convention appends the address of the peer it accepted the connection from. The result, read left to right, is: whatever the client chose to send, followed by one address per hop.
X-Forwarded-For: 198.51.100.7, 203.0.113.44, 10.0.3.19
^^^^^^^^^^^^ ^^^^^^^^^^^^ ^^^^^^^^^
client-supplied CDN's view edge LB's view
(untrusted) of client of the CDNYour trusted set is {CDN egress ranges, 10.0.3.0/24}. Reading from the right: 10.0.3.19 was written by the edge LB (trusted), 203.0.113.44 was written by the CDN (trusted). The next entry, 198.51.100.7, was written by the CDN and describes the peer the CDN accepted, which is the client. That is your answer. Everything to the left of it is text the client typed, and there may be an arbitrary amount of it.
The boundary is therefore not "which addresses appear in the list" but "which hops wrote entries into the list". A CIDR allowlist and a hop count are two ways of describing the same boundary, and both are only as good as the guarantee that nobody can reach your edge except through the hops you counted. That guarantee is a network control, not a header control, which is the theme of client IP spoofing through proxies.
nginx#
# requires --with-http_realip_module (present in the official packages)
set_real_ip_from 10.0.3.0/24;
set_real_ip_from 173.245.48.0/20; # one CDN range, repeat per range
real_ip_header X-Forwarded-For;
real_ip_recursive on;set_real_ip_from accepts an address, a CIDR, or unix:. real_ip_header can be X-Forwarded-For, X-Real-IP (the default), proxy_protocol, or any header name. The module rewrites $remote_addr and $binary_remote_addr in place and keeps the original peer available as $realip_remote_addr (nginx 1.9.7 and later) and $realip_remote_port (1.11.0 and later).
real_ip_recursive is the setting people get wrong. With it off (the default), nginx replaces the client address with the last address in the header. With it on, nginx walks the list from the right and takes the last address that is not in set_real_ip_from.
| Chain | XFF as received | recursive off result | recursive on result |
|---|---|---|---|
| client to nginx | 1.2.3.4 (forged) | 1.2.3.4 if peer trusted | same |
| client to CDN to nginx | client (one entry, written by the CDN) | client (correct) | client (correct) |
| client to CDN to LB to nginx | client, cdn_ip | cdn_ip (wrong) | client (correct) |
The rule: turn real_ip_recursive on whenever two or more trusted hops append to the header, which is every CDN plus load balancer topology. Leaving it off gives you the CDN's egress address in every log line and every rate limit bucket, and the bug looks like "all our traffic comes from twelve IP addresses".
The interaction that bites people: ngx_http_realip_module runs early enough that limit_req and limit_conn see the rewritten $binary_remote_addr. That is usually what you want, but it means an over-broad set_real_ip_from converts your rate limiter into a per-request-header limiter that any client can partition at will. Log both values so you can tell:
log_format ip '$realip_remote_addr $remote_addr "$http_x_forwarded_for"';HAProxy#
HAProxy has no trusted_proxies primitive. You express the boundary directly, which is more verbose and considerably more explicit:
frontend fe_https
bind :443 ssl crt /etc/haproxy/site.pem
acl from_edge src -f /etc/haproxy/trusted-proxies.lst
http-request set-src hdr_ip(x-forwarded-for,-1) if from_edge
option forwardfor
default_backend be_apphdr_ip(x-forwarded-for,-1) takes the last occurrence, index -1 counting from the end. set-src replaces src for every rule evaluated afterwards, including stick tables, src_conn_rate and logging, so place it early in the frontend. Note that this is the non-recursive behaviour: with two appending hops in front you need to walk further, either by using index -2 for a known fixed depth or by keeping the header rewriting in the outermost proxy only.
option forwardfor appends the current src to the header on the way out, and option forwardfor except 127.0.0.0/8 skips the append for the listed sources. HAProxy details are in HAProxy configuration for HTTP reverse proxying.
Apache httpd (mod_remoteip)#
RemoteIPHeader X-Forwarded-For
RemoteIPInternalProxy 10.0.3.0/24
RemoteIPTrustedProxy 173.245.48.0/20
RemoteIPProxiesHeader X-Forwarded-Bymod_remoteip walks the header right to left and stops at the first address that is in neither list. The two directives differ in two ways. Addresses matched by RemoteIPInternalProxy are consumed silently, while addresses matched by RemoteIPTrustedProxy are consumed and recorded in the header named by RemoteIPProxiesHeader. More importantly, an internal proxy is trusted to present any address, including private ones, whereas a private address presented by a trusted proxy (the 10/8, 172.16/12, 192.168/16, 169.254/16 and 127/8 blocks, or anything outside the public 2000::/3 IPv6 range) is not accepted as the client and is left in the header. Use internal for your own infrastructure and trusted for third-party edges you want an audit trail for. Both accept a ...List variant that reads a file, which matters when a CDN publishes a hundred ranges.
The module replaces r->useragent_ip, so %a in LogFormat stays the socket peer and %{c}a gives the corrected client address. Apache 2.4.31 and later also supports RemoteIPProxyProtocol On to take the address from the PROXY protocol instead.
Envoy#
# inside the http_connection_manager filter config
use_remote_address: true
xff_num_trusted_hops: 2Envoy is the odd one out because its primary model counts hops instead of matching addresses. With use_remote_address: true, Envoy treats itself as the edge: it appends the immediate downstream address to XFF and sets x-envoy-external-address to the address it decided is the client. xff_num_trusted_hops (default 0) says how many entries at the right-hand end of the received header were written by infrastructure you control, so the client address is the entry at that index counting from the right.
Hop counting differs from CIDR trust in a way that is easy to state and easy to get wrong: hop counting does not look at the addresses at all. It cannot detect that the header is shorter than expected. If a request arrives with a single-entry XFF and xff_num_trusted_hops: 2, Envoy has nothing to select and falls back to the downstream address; if an attacker sends a header with three entries when your CDN normally produces one, hop counting picks whichever entry lands at the counted index, which is an attacker-chosen value. It is safe only when the edge is genuinely unreachable except through the counted hops.
Recent Envoy versions add an original_ip_detection_extensions mechanism with an XFF extension that accepts xff_trusted_cidrs, giving CIDR semantics alongside hop counting. Check the API docs for the version you run before depending on it. Envoy's listener and filter model is covered in Envoy listeners, routes and clusters.
Traefik#
entryPoints:
websecure:
address: ":443"
forwardedHeaders:
trustedIPs:
- "10.0.3.0/24"
- "173.245.48.0/20"Traefik's default behaviour is good: if the connecting peer is not in trustedIPs, incoming X-Forwarded-* headers are not preserved, they are replaced with Traefik's own view. Setting forwardedHeaders.insecure: true disables the check entirely and preserves whatever the client sent, which should appear in exactly zero production configurations. The parallel proxyProtocol.trustedIPs and proxyProtocol.insecure settings control the same boundary for the PROXY protocol.
Caddy#
{
servers {
trusted_proxies static private_ranges 173.245.48.0/20
client_ip_headers X-Forwarded-For
}
}trusted_proxies takes a module name (static is the built-in) followed by ranges; private_ranges is a shorthand for loopback plus the private IPv4 and IPv6 ranges. Caddy resolves {client_ip} by walking the configured header from the right past trusted addresses, while {remote_host} always stays the socket peer, so you can log both with no extra configuration. With no trusted_proxies configured, {client_ip} and {remote_host} are identical, which is the correct fail-safe default.
Express#
app.set('trust proxy', value) accepts more forms than any other framework here, and the form you choose selects the trust model:
| Value | Meaning |
|---|---|
false (default) | req.ip is the socket address, req.ips is empty |
true | Trust everything: req.ip is the leftmost XFF entry, fully attacker controlled |
'10.0.3.0/24' or an array | CIDR allowlist: walk from the right, skip trusted, take the first remaining |
'loopback', 'linklocal', 'uniquelocal' | Named shorthands for the obvious ranges, combinable in a list |
A number n | Hop count: trust n hops closest to the app |
(ip, i) => boolean | Predicate evaluated per address from the right |
trust proxy: true is the single most common client-IP vulnerability in Node services, because it makes req.ip equal to the first token of a header the client wrote. Rate limiting middleware has started to flag it: express-rate-limit v7 emits a validation error when it detects a permissive trust proxy setting, precisely because keying a limiter on a spoofable value is worse than not limiting at all.
Django#
Django deliberately ships no client-IP-from-XFF setting. The SetRemoteAddrFromForwardedFor middleware that once did this was removed in Django 1.1 on the grounds that it cannot be made safe without knowing the deployment topology, and that position has not changed. request.META['REMOTE_ADDR'] is whatever the WSGI or ASGI server reported, and if you need it corrected, correct it in the proxy (nginx realip, mod_remoteip) or in a small middleware of your own that encodes your actual chain.
What Django does ship is the adjacent set:
USE_X_FORWARDED_HOST = True # default False
USE_X_FORWARDED_PORT = True # default False
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") # default NoneSECURE_PROXY_SSL_HEADER makes request.is_secure() believe a header, which drives HSTS, secure cookies and the SSL redirect middleware. It is safe only if the proxy unconditionally sets or strips that header for every request. If any path exists by which a client's own X-Forwarded-Proto: https reaches Django, an attacker can make a plaintext request look secure. The same reasoning applies to USE_X_FORWARDED_HOST and cache poisoning; see X-Real-IP, X-Forwarded-Proto, Host and Port.
Rails#
config.action_dispatch.trusted_proxies = [IPAddr.new("173.245.48.0/20")]ActionDispatch::RemoteIp reverses the candidate list (X-Forwarded-For, plus Client-Ip), removes every address matching a trusted proxy, and returns the first survivor. The important non-obvious behaviour is the default: with trusted_proxies unset, the built-in TRUSTED_PROXIES constant applies, and it contains 127.0.0.0/8, ::1, fc00::/7, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16 and fe80::/10. So an unconfigured app in an environment where an attacker can obtain an RFC 1918 address that reaches it (a shared VPC, a multi-tenant cluster) will happily skip past that address. Setting trusted_proxies to an enumerable replaces that constant rather than adding to it, so list every hop you need, including loopback if your app server is reached over it. Rails requires an enumerable here; passing a bare IPAddr raises an ArgumentError.
Rails also raises ActionDispatch::RemoteIp::IpSpoofAttackError when X-Forwarded-For and Client-Ip disagree, controlled by config.action_dispatch.ip_spoofing_check (default true). Treat that as a consistency check, not a security control.
Spring Boot#
server.forward-headers-strategy=native
server.tomcat.remoteip.internal-proxies=10\\.0\\.3\\.\\d{1,3}native delegates to the servlet container. On Tomcat that is RemoteIpValve, whose internalProxies property is a regular expression over the peer address, defaulting to loopback, the RFC 1918 blocks and link-local. Note the model mismatch: you are writing a regex that matches dotted-quad text, so a careless pattern like 10\..* and a correct-looking CIDR do not always mean the same thing.
framework uses Spring's ForwardedHeaderFilter, which understands both Forwarded (see the Forwarded header, RFC 7239) and X-Forwarded-*, but applies them with no source check whatsoever. Choose framework only when a proxy in front strips those headers from client requests. The default is none, except that Spring Boot switches to native automatically when it detects a known cloud platform.
CIDR allowlist or hop count: which to use#
| Situation | Use | Why |
|---|---|---|
| Fixed internal chain (LB to nginx to app) | CIDR | The addresses are stable and you own them |
| CDN with a published, machine-readable IP list | CIDR, refreshed automatically | Catches the case where a request bypasses the CDN |
| CDN whose ranges change faster than you can deploy | Hop count | A stale CIDR list silently starts returning the CDN's address as the client |
| Mixed traffic: some via CDN, some direct | CIDR | Chain depth varies per request, so a fixed count is wrong half the time |
| Kubernetes ingress where pod CIDRs are large and shared | Hop count, plus a network policy | An over-broad CIDR trusts every pod in the cluster |
The CDN case deserves the extra sentence: hop counting is more robust to IP churn only because it stops caring about addresses, so it also stops noticing when a request did not come through the CDN at all. If you adopt hop counting behind a CDN, the origin must be unreachable except from the CDN, via an IP allowlist, mutual TLS, or a secret header. Without that, hop counting is spoofing with extra steps.
Verifying the configuration with curl#
Expose a debug endpoint that prints both the socket peer and the derived client IP. In nginx:
location = /whoami {
default_type text/plain;
return 200 "peer=$realip_remote_addr derived=$remote_addr xff=$http_x_forwarded_for\n";
}Then run this sequence, the first three from outside your network and the last from a host inside the trusted range:
# 1. Baseline: no forged header
curl -s https://app.example.com/whoami
# 2. Single forged entry
curl -s -H 'X-Forwarded-For: 1.2.3.4' https://app.example.com/whoami
# 3. Forged loopback, the one that unlocks admin panels
curl -s -H 'X-Forwarded-For: 127.0.0.1' https://app.example.com/whoami
# 4. From a trusted host: the header must now be honoured
curl -s -H 'X-Forwarded-For: 1.2.3.4' http://10.0.3.50/whoami
# 5. Bypass the CDN and hit the origin directly
curl -s --resolve app.example.com:443:<origin-ip> \
-H 'X-Forwarded-For: 1.2.3.4' https://app.example.com/whoami| Test | Correct result |
|---|---|
| 1 | derived equals your real public address |
| 2 | derived still equals your real address, xff shows 1.2.3.4 |
| 3 | derived is never 127.0.0.1 |
| 4 | derived is 1.2.3.4, proving the trusted path works |
| 5 | Connection refused or reset; if it succeeds, your origin is not locked to the CDN |
Test 5 is the one people skip and the one that matters most. A perfect trusted-proxy configuration is worthless if the origin answers requests that never passed through the proxy. The client IP resolver will show you what a given header and trusted set resolve to before you deploy the change.
Failure modes#
nginx: [emerg] unknown directive "set_real_ip_from". The realip module is not compiled in. Official nginx.org packages and most distribution builds include it; minimal or custom builds may not. Rebuild with --with-http_realip_module.
All traffic appears to come from a handful of addresses. Classic real_ip_recursive off with two appending hops, or a CIDR list that is missing the innermost hop so the module never activates. Log $realip_remote_addr alongside $remote_addr to tell the two apart in one line.
Rate limits trigger for unrelated users. Your limiter is keyed on a value that resolves to a shared proxy address. In nginx that means limit_req_zone $binary_remote_addr is bucketing an entire CDN PoP together.
The app sees the right IP but generates http:// URLs behind TLS. Client IP and protocol are separate settings. You configured the address path and not X-Forwarded-Proto handling: SECURE_PROXY_SSL_HEADER in Django, forward-headers-strategy in Spring, real_ip_header does not cover this at all.
ActionDispatch::RemoteIp::IpSpoofAttackError in Rails logs. X-Forwarded-For and Client-Ip disagree. Usually a proxy in the chain is setting Client-Ip (some appliances do) rather than an actual attack, but confirm which hop adds it before disabling the check.
Adding or removing a CDN breaks client IPs silently. Only in hop-counted configurations. There is no error, the value just becomes wrong, and it becomes wrong in the direction of attacker control. Every change to chain depth needs the hop count reviewed in the same change.
Frequently asked questions#
Should real_ip_recursive be on or off?#
Turn it on if two or more trusted proxies append to X-Forwarded-For before the request reaches nginx, which is the case for any CDN plus load balancer topology. With it off (the default), nginx takes the last entry, which is the address of the second-to-last proxy rather than the client.
Does adding a trusted proxy list make X-Forwarded-For safe to use for authorisation?#
No. It makes the derived client IP trustworthy for logging, analytics and rate limiting, assuming the network guarantee holds. IP addresses are still shared, reassigned and easy to route around, so they should not be the sole basis for granting access to anything sensitive.
Why does Django have no X-Forwarded-For setting?#
Django removed the middleware that did this in version 1.1 because a framework cannot know your proxy topology, and a wrong guess creates a security hole rather than a bug. The documented approach is to fix the address in the proxy layer or in application-specific middleware that encodes your actual chain.
What is the difference between RemoteIPInternalProxy and RemoteIPTrustedProxy in Apache?#
Both mark an address as a proxy whose contribution to the header should be consumed. Internal proxies are consumed silently and may present any address, including private ones. Trusted proxies are consumed and recorded in the header named by RemoteIPProxiesHeader, giving an audit trail of which third-party edges handled the request, but a private address presented by a trusted proxy is not accepted as the client and stays in the header.
Is xff_num_trusted_hops safer than a CIDR list?#
It is more robust to a proxy's addresses changing and less robust to the chain depth changing, and its failure mode is worse. A stale CIDR list produces a visibly wrong client IP; a wrong hop count can select an attacker-supplied entry. Use hop counting only when the edge is unreachable except through the counted hops.
How do I trust a CDN whose IP ranges change frequently?#
Fetch the published range list on a schedule and render it into the configuration, then reload. Pair that with an origin lock so that even a stale list cannot be exploited: restrict the origin to the CDN ranges at the firewall, require mutual TLS, or require a secret header the CDN injects.
Can I set trusted proxies to 0.0.0.0/0 in a private network?#
Only if nothing untrusted can open a TCP connection to the listener, which in a shared VPC or a multi-tenant Kubernetes cluster is rarely true. 0.0.0.0/0 means every client can set its own address, and it is functionally identical to Express's trust proxy: true.
Where should the client IP be resolved: at the edge or in the application?#
At the outermost proxy you control, once. Resolving it in several places multiplies the number of trust configurations that must agree, and the usual failure is an edge that resolves correctly followed by an application framework that re-parses the same header with different defaults and gets a different answer.
Primary sources#
Every normative claim on this page is checked against the specification or the vendor documentation listed here. Where behaviour is version dependent, the version is named in the text.
- nginx ngx_http_realip_module
- Apache mod_remoteip
- Envoy HTTP connection manager, x-forwarded-for
- Traefik EntryPoints reference
- Caddy global options (trusted_proxies)
- Express behind proxies
- Django settings reference (SECURE_PROXY_SSL_HEADER)
- Rails ActionDispatch::RemoteIp
- Spring Boot application properties
- HAProxy configuration manual
Found something wrong, or behaviour that differs on your version? Report it with the version number and a primary source. Anything substantive is fixed in the page and logged on the corrections page. See editorial standards for how pages are researched, sourced and reviewed.