X-Real-IP, X-Forwarded-Proto, Host and Port
How X-Real-IP, X-Forwarded-Proto, X-Forwarded-Host and X-Forwarded-Port behave, the redirect loop they cause, and nginx $host vs $http_host vs $proxy_host.
Key points
X-Real-IPcarries a single address and is overwritten at every hop that sets it, so it is safe with exactly one trusted proxy and silently wrong with two.- A missing or ignored
X-Forwarded-Protobehind a TLS-terminating proxy produces an infinite301loop: the app redirects tohttps, the proxy forwardshttpagain, repeat. - nginx defaults to
proxy_set_header Host $proxy_host, so forgetting the directive sends the upstream's own name and breaks name-based virtual hosting. $hostis lowercased and port-stripped,$http_hostis the rawHostfield,$proxy_hostis theproxy_passtarget. They are not interchangeable.- Trusting
X-Forwarded-Hostfor absolute URL construction turns a header into a password-reset link hijack and a cache poisoning primitive.
X-Forwarded-For gets the attention, but the headers around it decide whether an application generates correct URLs, sets secure cookies, and avoids redirect loops. X-Real-IP carries one address and is overwritten rather than appended. X-Forwarded-Proto, X-Forwarded-Host and X-Forwarded-Port describe the connection the client made to the outermost proxy, which is the only description an application behind TLS termination has of its own public identity. All four are ordinary request headers with no authentication, so each is trustworthy exactly as far as the trusted-proxy configuration reaches.
| Header | Typically emitted by | Overwrite or append | Safe to trust when |
|---|---|---|---|
X-Forwarded-For | Almost every proxy, CDN and load balancer | Append | Only after a right-to-left walk across a trusted set |
X-Real-IP | nginx (proxy_set_header), Kubernetes ingress controllers | Overwrite | The connection peer is trusted and that peer sets the header itself on every request |
X-Forwarded-Proto | TLS-terminating proxies, AWS ALB, CDNs | Overwrite | The connection peer is trusted and strips any inbound copy |
X-Forwarded-Host | Proxies that rewrite Host | Overwrite | Validated against a hostname allowlist, never used raw for URL generation |
X-Forwarded-Port | AWS ALB, some reverse proxies | Overwrite | Same condition as X-Forwarded-Proto |
Host | The client | Client-supplied, rewritten by proxies | Always validated against an allowlist |
Forwarded | HAProxy 2.8 and later | Append | Same walk as X-Forwarded-For |
The "overwrite" rows are the important ones. Overwriting destroys evidence, which is a security property when the overwriter is trusted and a silent failure when it is not.
X-Real-IP: one value, last writer wins#
X-Real-IP is an nginx convention, set with proxy_set_header X-Real-IP $remote_addr;. It is not a list: each proxy configured to set it replaces whatever arrived, and each proxy not configured to set it passes the previous value through untouched. That single behaviour produces both its advantage and its failure mode:
- Safer with exactly one proxy. If your only edge sets it unconditionally, the value the application reads cannot be influenced by the client, because the edge overwrote whatever arrived. No list to walk, no leftmost entry to be tricked by, no trusted-CIDR set to maintain. For one nginx in front of one app, reading
X-Real-IPis simpler and no less correct than parsing X-Forwarded-For. - A footgun with two. Add a second proxy that does not set it, and the value read is whichever hop last wrote it, possibly several hops upstream, possibly the client. Nothing in the header distinguishes those cases: one address arrives either way.
X-Forwarded-Fordegrades visibly (short list, unfamiliar entries);X-Real-IPdegrades invisibly.
nginx can also consume it: real_ip_header X-Real-IP; with set_real_ip_from rewrites $remote_addr from the header. Because the header is a single value, real_ip_recursive has no effect on it.
X-Forwarded-Proto and the infinite redirect loop#
This is the classic. The application enforces HTTPS, the proxy terminates TLS, and the application never learns that TLS happened.
Setup: a load balancer terminates TLS on 443 and forwards plain HTTP to the application on 8080, correctly setting X-Forwarded-Proto: https. The application is Django with SECURE_SSL_REDIRECT = True and no SECURE_PROXY_SSL_HEADER.
- The browser requests
https://app.example.com/dashboard. - The load balancer terminates TLS and opens a plain HTTP connection to the application, adding
X-Forwarded-Proto: https. - The application evaluates
request.is_secure(). BecauseSECURE_PROXY_SSL_HEADERis unset, Django looks only at the WSGI environment of the connection it actually received, which is plain HTTP.is_secure()returnsFalse. SecurityMiddlewarereturns301 Moved PermanentlywithLocation: https://app.example.com/dashboard.- The browser follows the redirect to exactly the URL it already requested.
- Go to step 2.
The symptom is ERR_TOO_MANY_REDIRECTS in Chrome, "The page isn't redirecting properly" in Firefox, and from the command line:
curl -sSL --max-redirs 3 https://app.example.com/dashboard
# curl: (47) Maximum (3) redirects followedThe diagnostic that identifies it in one command is a single request showing a redirect whose target is the request itself:
curl -s -o /dev/null -D - https://app.example.com/dashboard | grep -i '^HTTP/\|^location'
# HTTP/1.1 301 Moved Permanently
# location: https://app.example.com/dashboardA 301 to the identical URL is definitive. Nothing other than a scheme or host mismatch produces it.
The fix is on the application side, because the proxy is already doing the right thing. Every framework has a switch that makes it trust the header:
| Framework | Setting |
|---|---|
| Django | SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") |
| Rails 7.1 and later | config.assume_ssl = true alongside config.force_ssl = true |
| Express | app.set("trust proxy", <subnet or hop count>), after which req.protocol reflects the header |
| Spring Boot | server.forward-headers-strategy=native (behind a proxy that sets the headers) or framework |
| Symfony | Request::setTrustedProxies($proxies, $bits) with the HEADER_X_FORWARDED_PROTO bit included |
Two adjacent bugs share this root cause. Secure-only cookies are dropped, because the framework will not set Secure on what it believes is a plain connection. And absolute URLs in responses (canonical links, Location on POST, OAuth redirect URIs) are emitted as http://, producing mixed-content warnings or a redirect_uri mismatch at the identity provider.
X-Forwarded-Host and the Host header#
Host is rewritten by proxies more often than any other header, and nginx's default is the surprising one: with no proxy_set_header Host, nginx sends Host: $proxy_host, the name and port from proxy_pass. A backend doing name-based virtual hosting sees Host: app_upstream instead of the client's hostname and serves the wrong vhost or a 404.
Take a request with Host: App.Example.COM:8443 arriving at a server block with server_name app.example.com;, listen 8443 ssl;, and proxy_pass http://backend:8080;:
| Variable | Expands to | Port included | Normalised | Use it when |
|---|---|---|---|---|
$host | app.example.com | No, stripped | Lowercased; falls back to server_name if no Host field | The upstream matches vhosts by name and you want a canonical value |
$http_host | App.Example.COM:8443 | Yes, verbatim | None; empty string if the client sent no Host | The upstream must see the exact bytes the client sent, including a non-default port |
$proxy_host | backend:8080 | Yes, from proxy_pass | n/a | Effectively never on purpose; this is the default you are overriding |
$server_name | app.example.com | No | The first name of the matched server block | You want a fixed canonical host regardless of what the client sent |
$server_port | 8443 | n/a | n/a | Populating X-Forwarded-Port |
$host comes from the absolute URI in the request line if present, otherwise the Host field, otherwise the matched server_name. That fallback is why $host is never empty and $http_host can be. The decision rule: use $host unless the upstream builds absolute URLs itself and is reached on a non-default port, in which case use $http_host and accept that you are forwarding unnormalised client input. Either way, validate the hostname at the edge. Interactions between proxy_pass, the URI and these variables are covered in nginx proxy_pass and the trailing slash, and you can try combinations in the nginx proxy_pass simulator.
X-Forwarded-Host exists for the case where the proxy overwrites Host with something else (a backend-internal name) but the application still needs the public hostname. If you set Host to the client's value, X-Forwarded-Host is redundant, and sending both invites the application to pick the wrong one.
Host header injection and cache poisoning#
An application that builds absolute URLs from X-Forwarded-Host (or from Host, or from Forwarded's host parameter) hands that URL's hostname to whoever sent the request.
The password reset flow is the canonical example.
POST /account/password-reset HTTP/1.1
Host: app.example.com
X-Forwarded-Host: attacker.example
Content-Type: application/x-www-form-urlencoded
email=victim@example.comThe application builds https://attacker.example/reset?token=... from the header and emails it to the real account owner. The victim clicks a plausible link in a genuine service email, and the token arrives at the attacker's server as a request path. No XSS, no interception, one header.
The cache poisoning variant needs no victim at all. If a caching layer keys on method, path and Host but not on X-Forwarded-Host, and the application reflects that header into an absolute URL in the response body (a script src, a canonical link, an API base URL in embedded JSON), one attacker request stores a poisoned response served to every later visitor. See caching in reverse proxies for cache key composition.
Four mitigations, in order of preference:
- Build absolute URLs from configuration, not from request headers. A service knows its own public hostname.
- Validate against an allowlist before use. Django does this for
Host(and forX-Forwarded-HostwhenUSE_X_FORWARDED_HOST = True) viaALLOWED_HOSTS, rejecting mismatches with a400and the log lineInvalid HTTP_HOST header: 'attacker.example'. You may need to add 'attacker.example' to ALLOWED_HOSTS. - Strip
X-Forwarded-Hostat the edge if the application does not need it, so the header cannot arrive at all. - If the application genuinely varies its output by that header, add it to the cache key with
Varyor an explicit cache key configuration.
Note that USE_X_FORWARDED_HOST defaults to False in Django precisely because enabling it moves the trust boundary outward: get_host() then prefers a header any client can set, and only ALLOWED_HOSTS stands behind it.
X-Forwarded-Port#
X-Forwarded-Port carries the port the client connected to on the outermost proxy, which the application cannot otherwise know when it listens on a different port internally. AWS Application Load Balancers set it alongside X-Forwarded-For and X-Forwarded-Proto.
It matters in exactly one situation: the public service runs on a non-default port and the application generates absolute URLs, because https://app.example.com:8443/callback cannot be reconstructed from a scheme and a hostname alone. On the default ports the scheme implies the port and the header adds nothing. Prefer carrying the port inside X-Forwarded-Host or the RFC 7239 host parameter, which keep host and port together and cannot disagree; see the Forwarded header (RFC 7239).
Worked nginx configuration#
An edge nginx terminating TLS, in front of an application that does not trust anything it is not given:
map $http_x_forwarded_proto $fwd_proto {
default $http_x_forwarded_proto;
"" $scheme;
}
server {
listen 443 ssl;
server_name app.example.com;
location / {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $fwd_proto;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_pass http://app_upstream;
}
}Three things to understand about it:
$proxy_add_x_forwarded_foris$http_x_forwarded_forplus", "plus$remote_addr, or just$remote_addrwhen the inbound header is absent. If this nginx is the internet-facing edge, it appends the client's forged entries. In that position, useproxy_set_header X-Forwarded-For $remote_addr;instead, which discards inbound values entirely.- The
$fwd_protomap preserves an upstream proxy'sX-Forwarded-Protoand falls back to nginx's own$schemewhen there is none. Using a bare$schemebehind another TLS terminator reportshttpand reintroduces the redirect loop. Using a bare$http_x_forwarded_protoat the edge lets the client choose. The map handles both positions correctly. X-Forwarded-Portuses$server_port, the port nginx accepted on, not$proxy_port.
Worked HAProxy configuration#
frontend fe_public
bind :443 ssl crt /etc/haproxy/certs/app.pem
bind :80
# Delete anything the client sent before writing our own.
http-request del-header X-Real-IP
http-request del-header X-Forwarded-Proto
http-request del-header X-Forwarded-Host
http-request del-header X-Forwarded-Port
http-request del-header X-Forwarded-For
# option forwardfor runs after http-request rules, so the header
# it writes contains exactly one entry: the connection peer.
option forwardfor
http-request set-header X-Real-IP %[src]
http-request set-header X-Forwarded-Host %[req.hdr(Host)]
http-request set-header X-Forwarded-Port %[dst_port]
# Rules run in order, so set the scheme before referencing it.
http-request set-header X-Forwarded-Proto https if { ssl_fc }
http-request set-header X-Forwarded-Proto http unless { ssl_fc }
default_backend be_appThe deletion block is the part most configurations omit. Without it, option forwardfor appends to whatever the client sent. Deleting first makes this frontend a trust boundary: every forwarded header downstream was written by HAProxy.
If HAProxy sits behind a CDN, remove the del-header X-Forwarded-For line, keep the rest, and restrict bind to the CDN's ranges; deleting the header there would discard the real client address. Use option forwardfor if-none when an upstream may or may not have already added it.
Failure modes#
Infinite redirect after moving TLS to a load balancer. Symptom: ERR_TOO_MANY_REDIRECTS, or a 301 whose Location equals the request URL. Cause: the app does not trust X-Forwarded-Proto. Fix: the framework setting from the table above, plus edge stripping of the inbound header.
One route loses the client IP, the rest are fine. Symptom: X-Forwarded-For absent for a single path. Cause: a proxy_set_header inside that location cancelled inheritance of the whole set. Fix: include the complete header set in that location.
Every request hits the wrong virtual host or 404. Symptom: the backend logs Host: app_upstream. Cause: missing proxy_set_header Host, so nginx sent $proxy_host. Fix: set Host $host.
Secure cookies are never set. Symptom: no session cookie, or Set-Cookie without Secure. Cause: the framework believes the connection is plaintext, the same root cause as the redirect loop.
400 Bad Request with Invalid HTTP_HOST header. Cause: the proxy forwards a Host or X-Forwarded-Host absent from ALLOWED_HOSTS, frequently a health check hitting the load balancer's IP. Fix: set Host explicitly on the health check rather than widening ALLOWED_HOSTS to *.
Client IP is internal on some requests only. Cause: X-Real-IP is set by one of several ingress paths. Fix: set it on every path, or use the client IP resolver walk over X-Forwarded-For instead.
Frequently asked questions#
What is the difference between X-Real-IP and X-Forwarded-For?#
X-Real-IP holds a single IP address and each proxy that sets it overwrites the previous value, while X-Forwarded-For is a comma-separated list that each proxy appends to. X-Forwarded-For preserves the whole chain and can be validated against a trusted-proxy set; X-Real-IP cannot be validated beyond the immediately preceding hop.
Why does my site redirect infinitely behind a load balancer?#
Because the application enforces HTTPS but sees a plain HTTP connection from the load balancer, so it redirects to the HTTPS URL the browser already requested. The load balancer sets X-Forwarded-Proto: https, but the framework ignores it until you explicitly enable proxy trust, for example Django's SECURE_PROXY_SSL_HEADER or Express's trust proxy.
Should I use $host or $http_host in proxy_set_header Host?#
Use $host in most cases: it is lowercased, has the port stripped, and falls back to server_name when the client sends no Host field. Use $http_host only when the upstream needs the exact bytes the client sent, typically because it generates absolute URLs on a non-default port.
Why does my backend see the upstream's name in the Host header?#
Because nginx's default is proxy_set_header Host $proxy_host, which is the host and port written in the proxy_pass directive. Set proxy_set_header Host $host; explicitly to forward the client's hostname.
Is it safe to trust X-Forwarded-Host?#
Only after validating it against an allowlist of hostnames you own. Any client can set it, and an application that builds password reset links or absolute URLs from it can be made to point those URLs at an attacker's domain. If the application does not need it, strip it at the edge.
Which of these headers should the edge proxy delete on the way in?#
Delete every one it intends to set itself: X-Real-IP, X-Forwarded-Proto, X-Forwarded-Host, X-Forwarded-Port, and X-Forwarded-For if the edge is genuinely internet-facing. Deleting first and writing second is what makes a proxy a trust boundary rather than a relay for client-supplied values.
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_proxy_module
- nginx ngx_http_core_module (embedded variables)
- nginx ngx_http_realip_module
- HAProxy configuration manual
- AWS Application Load Balancer X-Forwarded headers
- Django settings: SECURE_PROXY_SSL_HEADER
- Django HttpRequest.get_host and USE_X_FORWARDED_HOST
- Express behind proxies
- RFC 9110: HTTP Semantics
- RFC 7239: Forwarded HTTP Extension
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.