Behind a CDN, r.RemoteAddr is the edge's IP — how do you recover the client address in Go?
answer
- it is the connection's peer, not the user
- host and port together, and IPv6 has colons
- the stdlib reads no forwarded list for you
- one entry appended per hop, left to right
- count in from the right by hops you run
basics
~20 sr.RemoteAddr is the connection's peer, which behind an edge is the proxy, in host:port form, so split it with net.SplitHostPort. Go never reads X-Forwarded-For for you; parse that list and count in from the right.
solid answer
~40 sGo's HTTP server sets `r.RemoteAddr` to the connection's peer as `IP:port` — `203.0.113.9:54321`, or `[2001:db8::1]:54321` for IPv6 — so behind a CDN it is the edge, not the user. Parse it with `net.SplitHostPort`, never `strings.Split(addr, ":")`, which mangles IPv6, and check the error, since the format is only guaranteed for the stdlib's TCP server. Nothing in `net/http` looks at `X-Forwarded-For`; if you want the original client you read it yourself. It is a list with one entry appended per hop, each hop recording the peer it received the request from, and it can arrive spread over several header lines, so flatten `r.Header.Values("X-Forwarded-For")` and split each line on commas. Then index in from the right by the number of proxies you actually operate: everything further left was written by something you do not control.
code
go · 16 linesfunc clientIP(r *http.Request, trustedHops int) string {
var list []string
for _, line := range r.Header.Values("X-Forwarded-For") {
for _, v := range strings.Split(line, ",") {
list = append(list, strings.TrimSpace(v))
}
}
if i := len(list) - trustedHops; i >= 0 && i < len(list) {
return list[i] // written by a hop we operate
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}go deeper
Know that r.RemoteAddr is a string holding both host and port, and that getting just the address means calling net.SplitHostPort rather than slicing the string yourself.
Explain why the address is the edge's rather than the user's, and how the forwarded list is built one entry per hop with each hop recording the peer it received from.
Show the whole diagnosis and fix: dump the request to see what the edge really sent, flatten repeated lines and commas, index in from the right by a configured hop count, and validate every entry before it reaches a log or a limiter.
Make the trusted-hop count a deployment setting that defaults to zero, and settle once, for the whole platform, which component writes the forwarded address and which services are allowed to read it.
## What r.RemoteAddr is `Request.RemoteAddr` is a plain `string` holding the network address of the peer at the other end of the connection this request arrived on. The `net/http` server fills it in before invoking your handler, in `IP:port` form: - IPv4: `203.0.113.9:54321` - IPv6: `[2001:db8::1]:54321` — bracketed, because the address itself is full of colons The field's documentation is deliberately careful: the format is *not* defined in general, it is what this package's server happens to write. `httputil.ReverseProxy` passes the inbound value through, a request produced by `http.ReadRequest` has it empty, and a listener that is not TCP can leave something else there entirely. So parse defensively. ## Parsing it host, port, err := net.SplitHostPort(r.RemoteAddr) if err != nil { host = r.RemoteAddr // no port, or not the shape we expected } `net.SplitHostPort` understands the bracketed IPv6 form and strips the brackets, and it returns an error rather than nonsense when there is no port. `strings.Split(r.RemoteAddr, ":")` is the bug this exists to prevent: on `[2001:db8::1]:54321` it returns a fistful of fragments, and code that takes element zero ends up rate-limiting or logging `[2001` forever. ## Behind a proxy the peer is the proxy If a CDN or an edge proxy terminates the connection from the user and opens its own connection to you, the peer of *your* connection is the edge. `r.RemoteAddr` is therefore correct and useless: correct because it truly is who is talking to you, useless because every request appears to come from a handful of edge addresses. This is the failure people meet as "every log line and every per-client limit sees the same three IPs". ## What X-Forwarded-For is, mechanically The convention is that each proxy in the chain **appends the address of the peer it received the request from**. For `user → edgeA → edgeB → you`: - edgeA appends the user's address; - edgeB appends edgeA's address; - your `r.RemoteAddr` is edgeB. So the header arrives as `user, edgeA` and the whole chain is recoverable only if you know how much of it your own infrastructure wrote. Two mechanical details bite: 1. **It can arrive on several lines.** A field may repeat, and each hop may have added its own line rather than extending an existing one. Read `r.Header.Values("X-Forwarded-For")`, not `Get`, then split each line on commas and `strings.TrimSpace` the pieces into one flat list. 2. **`net/http` never touches it.** There is no built-in "real IP" support in the standard library. The header is ordinary request data until your code does something with it. ## Indexing in from the right If you operate `n` hops that append to the list, the rightmost `n` entries were written by software you control, and the entry at index `len(list) - n` is the furthest-left address you have any reason to believe. Everything to its left was written by something upstream of your trust boundary and could be anything at all — a client can send the header itself before the first hop ever sees the request, and its entries will simply sit at the front of the list. With `n = 0` — no proxy in front of you — there is no trustworthy entry at all and the only address you know is `r.RemoteAddr`. Make `n` configuration with a default of zero, so a service that is moved out from behind its edge fails closed rather than believing whatever it is told. ## Validate every entry Entries are strings other people's software wrote. They may be empty, padded with spaces, an obfuscated identifier, a literal `unknown`, or an address with a port attached. Before an entry reaches a log field, a rate-limiter key or a geo lookup, run it through `netip.ParseAddrPort` and then `netip.ParseAddr`, and discard it if neither parses. An unvalidated string flowing into a keyed store is how a header becomes an unbounded cardinality problem. ## The diagnostic When the recovered address looks wrong, print what actually arrived before theorising: `httputil.DumpRequest(r, false)` shows every header line the edge sent, including how many forwarded lines there are and in what order, alongside the `r.RemoteAddr` you can log next to it. Comparing those two side by side answers, in one look, both "is the edge sending the header at all" and "how many hops are appending to it". ## Where the judgment sits How far to the left you are willing to believe is a deployment property, not a code property: it depends on which components are yours and whether anything can reach your service without passing through them. Encode it as a number in configuration and keep the parsing code dumb.
- Why use net.SplitHostPort on r.RemoteAddr instead of splitting on a colon?An IPv6 address is full of colons and the server writes it bracketed, `[2001:db8::1]:54321`, so a naive split returns fragments and code taking element zero gets `[2001`. SplitHostPort strips the brackets, returns host and port separately, and errors when there is no port at all — which is why you check the error and fall back to the raw string.
- The service is exposed directly with no proxy in front. What should the handler do with X-Forwarded-For?Ignore it. With no hop of your own in the path, any client can send the header with any content, so the only address you actually know is the connection peer in `r.RemoteAddr`. Keep the trusted-hop count in configuration, default it to zero, and let deployment raise it only when a proxy you operate is genuinely in front.
- An entry in the forwarded list is not an address at all. How should the code handle it?Validate before use: try `netip.ParseAddrPort`, then `netip.ParseAddr`, and drop the entry if neither succeeds. Entries are written by other people's software and may be empty, padded, port-suffixed or an opaque token, so nothing unparsed should reach a log field, a rate-limiter key or a geo lookup.
- Why read the forwarded field with Header.Values rather than Header.Get?Get returns only the first line. The field can arrive on several lines when different hops each add their own instead of extending one, and Get would silently drop everything after the first — which shifts your index from the right and makes you return the wrong entry. Values gives every line, and you flatten them on commas.
saying these in an interview costs you the question
- Splits r.RemoteAddr on a colon and breaks IPv6
- Expects net/http to fill RemoteAddr from a forwarded header
- Takes the leftmost forwarded entry as the client
- Ignores the error from net.SplitHostPort
- Reads only the first X-Forwarded-For header line