The Forwarded header (RFC 7239)
The RFC 7239 Forwarded grammar in full: for, by, host and proto, node identifiers, mandatory quoting, parser pitfalls, and which proxies emit it.
Key points
Forwardedis the standards-track replacement forX-Forwarded-For, defined in RFC 7239 (June 2014), carryingfor,by,hostandprotoin one comma-separated list of semicolon-separated pairs.- Quotes are mandatory whenever a value contains a character outside the token set, which means every
ip:portform and every IPv6 address (for="[2001:db8::1]:4711"). - Node identifiers may be an IPv4 literal, a bracketed IPv6 literal, the literal
unknown, or an obfuscated identifier beginning with an underscore (for=_hidden). - Comma separates forwarded-elements at the top level and semicolon separates pairs inside one element, so a parser must respect quoted strings before splitting on either.
- Adoption stalled because safe appending requires validating the untrusted inbound value first, whereas appending to
X-Forwarded-Foris a string concatenation.
Forwarded is the standards-track header field that replaces the X-Forwarded-* family, defined by RFC 7239 in June 2014. One header carries the client node, the receiving interface, the original Host and the original scheme as four parameters (for, by, host, proto) inside a semicolon-separated forwarded-element, and multiple elements are joined with commas in client-first order. It solves the ambiguities that make X-Forwarded-For awkward, notably IPv6 and port representation, at the cost of a genuine parser: values may be quoted strings, and quoting is mandatory for any value containing a character outside the HTTP token set.
| Parameter | Meaning | RFC 7239 | Typical value |
|---|---|---|---|
for | The node that initiated the request to the proxy | Section 5.2 | for=192.0.2.60, for="[2001:db8::1]:4711" |
by | The interface of the proxy where the request came in | Section 5.1 | by=203.0.113.43, by=_lb01 |
host | The Host header field as received by the proxy | Section 5.3 | host=example.com, host="example.com:8443" |
proto | The protocol used to reach the proxy | Section 5.4 | proto=https |
The grammar#
RFC 7239 section 4 defines the field as a list of elements, each a semicolon-separated list of name=value pairs:
Forwarded = 1#forwarded-element
forwarded-element = [ forwarded-pair ] *( ";" [ forwarded-pair ] )
forwarded-pair = token "=" value
value = token / quoted-stringThree consequences follow directly from that ABNF and are easy to miss:
- A forwarded-pair is optional on both sides of a semicolon, so
for=192.0.2.60;;proto=httpsand a trailing;are grammatically valid. A parser must tolerate empty segments rather than erroring. - The pair name is a bare
token, so parameters other than the four registered ones are syntactically legal. RFC 7239 requires recipients to ignore parameters they do not recognise, not to reject the header. - A parameter must not occur more than once within a single forwarded-element. Two
forvalues in one element is a protocol violation; two elements each with oneforis the normal multi-hop case.
Node identifiers have their own grammar in section 6:
node = nodename [ ":" node-port ]
nodename = IPv4address / "[" IPv6address "]" / "unknown" / obfnode
node-port = port / obfport
port = 1*5DIGIT
obfnode = "_" 1*( ALPHA / DIGIT / "." / "_" / "-" )
obfport = "_" 1*( ALPHA / DIGIT / "." / "_" / "-" )unknown is a literal, used when a node is known to exist but its address is unavailable or not disclosable. An obfuscated identifier is any token beginning with _, chosen by the operator and stable enough to correlate requests within one deployment without revealing an address. The port may be obfuscated too: for="192.0.2.60:_hidden" is well formed.
When quotes are mandatory#
The rule is mechanical: a value that is not a valid token must be a quoted-string. The token character set (RFC 9110) is alphanumerics plus !#$%&'*+-.^_ ` |~. It does not include :, [, ], / or space. Therefore:
| Value | Valid | Why |
|---|---|---|
for=192.0.2.60 | Yes | An IPv4 literal is a token (digits and dots) |
for=192.0.2.60:4711 | No | : is not a token character |
for="192.0.2.60:4711" | Yes | Quoted, so the colon is allowed |
for=2001:db8::1 | No | Colons, and IPv6 must be bracketed |
for=[2001:db8::1] | No | Brackets are not token characters |
for="[2001:db8::1]" | Yes | Bracketed and quoted |
for="[2001:db8:cafe::17]:4711" | Yes | The canonical IPv6-with-port form |
for=_hidden | Yes | Obfuscated node, leading underscore, all token characters |
for=unknown | Yes | The literal defined in section 6.2 |
host=example.com | Yes | Token |
host=example.com:8443 | No | Must be quoted because of the colon |
proto=https | Yes | Token |
Every IPv6 form and every port-bearing form requires quotes. This is the commonest emitter bug: code that string-formats for= plus an address emits an invalid field for every IPv6 client, and strict parsers discard the whole header.
Parameter names are case-insensitive, as are the literal unknown and the hex digits of an IPv6 literal. For=, FOR= and for= are the same parameter. Obfuscated identifiers should be compared byte for byte, since RFC 7239 does not define a case-folding rule for them and folding would collide distinct identifiers.
Multiple elements and multiple header fields#
The order is the same as X-Forwarded-For: leftmost element is closest to the client, rightmost is closest to you. Forwarded is a comma-separated list field, so under RFC 9110 a sender may split it across several field lines and a recipient may combine them. These two messages are semantically identical:
Forwarded: for=192.0.2.43, for="[2001:db8:cafe::17]", for=unknownForwarded: for=192.0.2.43
Forwarded: for="[2001:db8:cafe::17]"
Forwarded: for=unknownA parser must therefore read all field lines with the name Forwarded, join them with commas in received order, and only then split into elements. Frameworks whose header accessor returns the first field line will silently drop hops, and an attacker who can inject a second field line ahead of a legitimate one gets the same first-value confusion described for duplicate X-Forwarded-For fields.
The same right-to-left trust walk applies: discard elements while the for node is in your trusted set, and take the first that is not. Forwarded does not change the trust model, only the encoding, so configuring trusted proxies remains the prerequisite. The client IP resolver applies the same walk to either header.
Mapping from the X-Forwarded-* family#
X- header | Forwarded parameter | Notes |
|---|---|---|
X-Forwarded-For | for | One element per hop rather than one list entry per hop |
X-Forwarded-Proto (also X-Forwarded-Scheme, X-Url-Scheme) | proto | Token or quoted-string matching a URI scheme name |
X-Forwarded-Host | host | Must be quoted when it carries a port |
X-Forwarded-Port | No direct equivalent | The port lives inside host, or as the by node-port for the receiving interface |
X-Real-IP | No equivalent | Nearest analogue is the leftmost for, but X-Real-IP is overwritten rather than appended |
| No equivalent | by | Identifies which of the proxy's own interfaces received the request; nothing in the X- family carries this |
by is the parameter with no legacy counterpart, and the one that earns the header on multi-homed edges: an origin can distinguish "arrived on the public listener" from "arrived on the internal listener" without a bespoke header.
Worked example: emitting Forwarded from nginx#
nginx has no built-in Forwarded support, so the element is constructed with map. The IPv4 and IPv6 cases need different quoting, which is exactly why a plain proxy_set_header line will not do:
map $remote_addr $proxy_forwarded_elem {
~^[0-9.]+$ "for=$remote_addr";
~^[0-9A-Fa-f:.]+$ "for=\"[$remote_addr]\"";
default "for=unknown";
}
server {
listen 443 ssl;
server_name app.example.com;
location / {
proxy_set_header Forwarded "$proxy_forwarded_elem;by=_edge01;proto=$scheme;host=\"$http_host\"";
proxy_pass http://app_upstream;
}
}A request over TLS from 2001:db8::1 to app.example.com:8443 produces:
Forwarded: for="[2001:db8::1]";by=_edge01;proto=https;host="app.example.com:8443"host is quoted unconditionally because $http_host includes the port whenever the client sent one, and an unquoted colon would make the field invalid. by uses an obfuscated identifier so the internal listener address is not disclosed.
Why adoption stalled#
Stated without judgement, the mechanics are:
X-Forwarded-Foralready worked for the dominant case. RFC 7239 arrived in 2014 into an ecosystem where every CDN, load balancer and framework already emitted and consumed theX-form, and RFC 6648's deprecation of theX-prefix applies to new fields, not to removing deployed ones.- Parsing is strictly harder. A conforming consumer needs a quoted-string tokeniser with escape handling, not
split(","). - Safe appending is harder still, for the reason above.
- The benefit is additive, not substitutive. A consumer that adds
Forwardedsupport must still supportX-Forwarded-For, because upstream proxies it does not control emit only that. No implementation gets to delete code. - No forcing function. Browsers do not send it, no security control requires it, and there is no interop test suite that fails a proxy for omitting it.
The result is a header that is well specified, widely parseable, and rarely emitted.
Parser pitfalls#
Commas inside quoted strings. A naive value.split(",") breaks on any quoted value containing a comma. The node grammar does not permit commas, but host and unregistered parameters take an arbitrary quoted-string, so Forwarded: for=192.0.2.1;host="a,b" splits into two elements under a naive parser and one under a correct one. If an attacker controls a value that reaches a Forwarded emitter, that difference is a hop-count manipulation.
Semicolon versus comma precedence. Comma is the outer separator and semicolon the inner one. Splitting on ; first and then on , produces proto=https, for=192.0.2.2 as a single value and loses the element boundary. Always split on top-level commas first, then on semicolons within each element, and treat both as inactive inside quotes.
Escaped quotes. quoted-string permits \" and \\. A tokeniser that ends the string at the first " after the opening one mis-terminates values containing an escaped quote.
Optional whitespace. Optional whitespace is allowed around the list separators. for=192.0.2.1 , for=192.0.2.2 is valid; trim each element.
Missing for. An element may legitimately contain only proto and host. Treat that as an unidentified hop, not a parse failure, and do not let it shift your position in the list. Similarly, ;; is legal: filter empty segments instead of emitting a pair with an empty name.
Which software emits and consumes it#
| Software | Emits Forwarded | Consumes Forwarded | Notes |
|---|---|---|---|
| HAProxy | Yes, option forwarded (2.8 and later) | Yes, rfc7239_is_valid, rfc7239_field, rfc7239_n2nn, rfc7239_n2np converters (2.8 and later) | With no arguments option forwarded populates proto and for; by, host and the port forms are opt-in |
| nginx | Not built in; construct with map and proxy_set_header | No, realip parses comma-separated address lists only | real_ip_header can name any header but expects X-Forwarded-For syntax |
| Envoy | No | No built-in parser; original IP detection uses XFF or a custom-header extension | The custom-header extension expects a bare address, not RFC 7239 syntax |
Apache httpd mod_remoteip | No | No, RemoteIPHeader expects an address list | Pointing RemoteIPHeader at Forwarded does not work |
| Caddy | No, header_up can add one manually | No, client_ip_headers expects address lists | |
| Spring Framework | Via ForwardedHeaderFilter | Yes, ForwardedHeaderFilter handles Forwarded and X-Forwarded-* | |
| Symfony HttpFoundation | No | Yes, Request::setTrustedProxies with the HEADER_FORWARDED bit | Can be trusted alongside the X-Forwarded-* bits, but a disagreement between the two raises ConflictingHeadersException |
The practical reading of that table: HAProxy is the one widely deployed intermediary that both emits and parses it natively, which makes an all-HAProxy chain the only common topology where Forwarded works end to end without custom configuration. See HAProxy configuration for HTTP reverse proxying for where these directives sit in a frontend.
Failure modes#
The whole header is ignored for IPv6 clients. Symptom: client IP resolves correctly for IPv4 and falls back to the proxy address for IPv6. Cause: an emitter writing for=2001:db8::1 without brackets or quotes, and a strict consumer rejecting the element. Fix: bracket and quote, as in the map above.
Client IP is wrong only when a port is present. Symptom: the resolved address is truncated at the first colon, or the parse fails. Cause: splitting the node on : without handling the bracketed IPv6 form. Fix: strip brackets first, then split the port from what remains.
Forwarded and X-Forwarded-For disagree. Symptom: two components of the same system log different client IPs for one request. Cause: one hop rewrote X-Forwarded-For and left Forwarded untouched, or the reverse. Fix: pick one header as authoritative at the trust boundary and delete the other, or regenerate both from the same resolved value.
A hop disappears from the chain. Symptom: the trust walk terminates one element early and returns a proxy address. Cause: the framework read only the first Forwarded field line. Fix: use the all-values accessor and join with commas before parsing.
An injected element passes the trust walk. Symptom: an attacker-chosen address appears as the client. Cause: the trusted set is empty or too broad, so the walk stops immediately at the rightmost element the attacker supplied. This is the same root cause as client IP spoofing through proxies and is not fixed by switching header formats.
Generated URLs point at an attacker domain. Symptom: password reset links or cached responses containing a foreign hostname. Cause: host from Forwarded used for absolute URL construction without validating it against an allowlist. The mitigation is identical to the X-Forwarded-Host case covered in X-Real-IP, X-Forwarded-Proto, Host and Port.
Frequently asked questions#
What is the difference between Forwarded and X-Forwarded-For?#
Forwarded is standardised by RFC 7239 and carries the client node, the receiving interface, the original host and the original scheme in one field; X-Forwarded-For is an unstandardised list of addresses that needs sibling X-Forwarded-Proto and X-Forwarded-Host headers to convey the same information. Forwarded also defines unambiguous IPv6 and port syntax, which X-Forwarded-For does not.
When do I have to quote a Forwarded value?#
Whenever the value contains a character outside the HTTP token set. In practice that means every IPv6 address (which must also be bracketed) and every value containing a port or a space, so for="[2001:db8::1]:4711" and host="example.com:8443" are quoted while for=192.0.2.60 and proto=https are not.
What does for=_hidden mean in a Forwarded header?#
It is an obfuscated node identifier, defined in RFC 7239 section 6.3. Any identifier beginning with an underscore is an operator-chosen alias for a real node, letting a proxy correlate requests from the same client without disclosing the address. Treat it as opaque and never as an IP.
Is Forwarded more secure than X-Forwarded-For?#
No. Both are ordinary request headers that any client can set, and both require the same right-to-left walk against an explicit trusted-proxy set. The grammar removes parsing ambiguity, which eliminates a class of parser-differential bugs, but it provides no authentication of any kind.
Does nginx support the Forwarded header?#
Not natively. nginx neither emits nor parses it, and the realip module expects comma-separated addresses, so pointing real_ip_header at Forwarded fails. You can emit a conforming header using map plus proxy_set_header, as shown above.
Can Forwarded appear more than once in a request?#
Yes. It is a comma-separated list field, so RFC 9110 lets a sender split it across multiple field lines. Parsers must read every Forwarded field line, join them with commas in received order, and then split into elements; reading only the first line loses hops.
Should new deployments use Forwarded instead of X-Forwarded-For?#
Emit both and consume both. Emitting Forwarded costs little and helps conforming consumers, but dropping X-Forwarded-For breaks any downstream component that only understands the legacy form, which today is most of them.
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.
- RFC 7239: Forwarded HTTP Extension
- RFC 9110: HTTP Semantics
- RFC 6648: Deprecating the "X-" Prefix and Similar Constructs
- HAProxy configuration manual
- nginx ngx_http_map_module
- nginx ngx_http_proxy_module
- Spring Framework filters (ForwardedHeaderFilter)
- Symfony - Proxies
- Envoy original IP detection - custom header
- Apache httpd mod_remoteip
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.