What you'll learn: By the end of this lesson you will be able to tell a forward proxy from a reverse proxy by who chose it, list what a reverse proxy does for the servers behind it, explain why the application stops seeing the client's address and how
ForwardedandX-Forwarded-Forbring it back, say why those headers must not be trusted blindly, configure nginx and Envoy to trust only known proxies, describe TLS termination and re-encryption, and say when a proxy may cache a response.
Two kinds of go-between exist in an office. A personal assistant makes calls on your behalf: the outside world sees the assistant, and you chose that assistant. A receptionist at the front desk takes every visitor's request for the company: visitors do not choose the receptionist, and they do not know which department answers.
A forward proxy is the assistant: it sits in front of clients. A reverse proxy is the receptionist: it sits in front of servers. This lesson extends Load Balancing, which covers layers 4 and 7, algorithms and health checks, with the parts a balancer shares with every reverse proxy: what the application loses, what it must be told, and what the proxy may store. The proxy's transport choices (HTTP versions, keep-alive) are in HTTP/1.1, HTTP/2 and HTTP/3 on the Server.
Verified against RFC 9110, 7239, 9111 and 9114, the nginx documentation (proxy, upstream, realip, SSL modules) and the Envoy documentation (development build 1.41.0-dev, HTTP headers page), all read on 2026-11-08. Defaults change between releases, so check the page for the version you run.
The core idea
A proxy is defined by who chose it and which side it serves. RFC 9110 section 3.7 gives the terms:
| Term | RFC 9110 wording | Plain meaning |
|---|---|---|
| Proxy | "a message-forwarding agent that is chosen by the client" | Forward proxy: the client is configured to use it |
| Gateway (reverse proxy) | "acts as an origin server for the outbound connection" | The client thinks it is talking to the origin |
| Tunnel | "acts as a blind relay between two connections without changing the messages" | Passes bytes, such as TLS, without reading them |
| Interception proxy | "filters or redirects outgoing TCP port 80 packets" | Not chosen by the client |
The same software can play either role. The illustrations are a minimal example (an address chain), a realistic example (ShopStream's edge) and a failure example (a spoofed header).
Figure 1. The two directions. How to read this: the top path is an organisation's outbound traffic leaving through a forward proxy it chose. The bottom path is inbound traffic meeting a reverse proxy that the client never selected. The software in both boxes can be the same.
Forward proxies in one page
A forward proxy lets an organisation see and control outbound traffic: an egress allow-list, logging, authentication of the caller, and sometimes caching. For HTTPS the client asks the proxy to open a tunnel with CONNECT: RFC 9110 section 9.3.6 says "CONNECT is intended for use in requests to a proxy", and in HTTP/2 and HTTP/3 it "is used to establish a tunnel over a single stream" (RFC 9114 section 4.4).
For a back-end engineer the main use is controlling what your own servers can call. A service that fetches user-supplied URLs should send them through an egress proxy that refuses internal addresses; see SSRF and the OWASP API Top 10. The rest of this lesson concerns the reverse proxy.
What a reverse proxy does
Everything a shopper reaches goes through one front door. The jobs below are the usual ones, with the nginx setting that implements each and its documented default.
| Job | Why | nginx setting (default) |
|---|---|---|
| Route by host and path | One domain, many services | proxy_pass in a location |
| Terminate TLS | One place for certificates; backends see plain HTTP | ssl_protocols (TLSv1.2 TLSv1.3) |
| Pool upstream connections | Fewer handshakes to the app | upstream keepalive |
| Retry or fail over | Hide one failed backend | proxy_next_upstream (error timeout) |
| Cache shared responses | Answer without touching the app | proxy_cache (off) |
Retries change semantics: nginx does not retry POST, LOCK or PATCH unless non_idempotent is set, and cannot retry once a response has started to reach the client.
What the application loses, and how it gets it back
The application's TCP peer is now the proxy. Three facts about the original request are lost unless the proxy passes them on: the client address, the scheme (the user used HTTPS, the app sees HTTP) and the host the user typed. Without them, IP rate limits, audit logs and absolute links go wrong.
RFC 7239 defines the standard header, Forwarded, whose abstract says it "allows proxy components to disclose information lost in the proxying process", and its introduction says the common practice was non-standard fields "such as X-Forwarded-For". Its parameters:
for: the client that made the request to this proxy.by: the interface of the proxy that received it.host: the originalHostheader.proto: the scheme used, so an origin behind TLS offloading can "rewrite URLs in a document".
IPv6 addresses must be quoted and in brackets: for="[2001:db8:cafe::17]". RFC 7239 section 7.4 shows the conversion: X-Forwarded-For: 192.0.2.43, 2001:db8:cafe::17 becomes Forwarded: for=192.0.2.43, for="[2001:db8:cafe::17]". In practice most software still sends the X-Forwarded-* family, so read the documentation of each hop.
Minimal example. Two proxies sit between a client at 203.0.113.9 (a documentation address) and the app. The CDN appends the client to X-Forwarded-For; the edge proxy appends the CDN's address:
Client 203.0.113.9 -> CDN 198.51.100.7 -> Edge proxy 192.0.2.20 -> App
At the app: TCP peer = 192.0.2.20
X-Forwarded-For: 203.0.113.9, 198.51.100.7
In nginx, $proxy_add_x_forwarded_for is "the client's X-Forwarded-For header with $remote_addr appended, comma-separated" (if the header is absent it is just $remote_addr), set by proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;. The last entry is the address the last trusted hop saw; everything before it was written by someone else.
Figure 2. The header is a list each hop extends. How to read this: every proxy adds the address it saw at its own socket. The app can trust an entry only if the hop that wrote it is trusted.
Do not trust the header blindly
RFC 7239 section 8.1 is blunt: the information "cannot be relied upon to be correct" and "may be modified... by every node on the way to the server, including the client making the request". A client can send its own X-Forwarded-For: 10.0.0.1 and, with a naive proxy, the app will read it. The rule is to trust an entry only if the connection that delivered it came from a proxy you control, and to count from the right.
- nginx. The realip module takes
set_real_ip_fromfor trusted addresses,real_ip_header(defaultX-Real-IP, orX-Forwarded-For, orproxy_protocol), andreal_ip_recursive. Off, the address is replaced by the last address in the header. On, it is replaced by "the last non-trusted address". The original address stays in$realip_remote_addr. - Envoy. With
use_remote_addresstrue Envoy treats the immediate peer as the client, appends it toX-Forwarded-For, and ignores a client-supplied header when computing the trusted address. With it false, the trusted address is the rightmost entry, or the (N+1)th from the right whenxff_num_trusted_hopsis N. Its page: "Malicious clients can forge XFF, but the last address in XFF can be trusted if it was put there by a trusted proxy." Downstreamx-forwarded-protois trusted only whenxff_num_trusted_hopsis non-zero.
# Edge proxy behind a CDN (illustrative; addresses are documentation ranges)
set_real_ip_from 198.51.100.0/24; # the CDN's published ranges, kept current
real_ip_header X-Forwarded-For;
real_ip_recursive on;
location / {
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://app_pool;
}
This is documented configuration, not output from a run. RFC 7239 also notes the field "exposes information that some users consider privacy sensitive".
Other headers a proxy touches
RFC 9110 section 7.6.1 says intermediaries "MUST parse a received Connection header field before a message is forwarded" and remove the fields it lists, because connection options are hop by hop. Section 7.6.3 says "a proxy MUST send an appropriate Via header field" in each message it forwards. nginx's proxy_set_header defaults are Host $proxy_host and Connection close, and the directives are inherited from the level above "if and only if there are no proxy_set_header directives defined on the current level", a common cause of a vanished Host header.
TLS termination and re-encryption
Terminating TLS at the proxy gives one place for certificates and cipher policy. nginx's ssl_protocols defaults to TLSv1.2 TLSv1.3. The cost is a plaintext hop inside your network. If policy requires encryption to the backend, the proxy opens a second TLS connection, and here the defaults need care: nginx's proxy_ssl_verify is off by default, so the proxy does not verify the backend's certificate unless you turn it on (with proxy_ssl_trusted_certificate), and proxy_ssl_server_name (SNI to the backend) is also off. Mutual TLS between services is the mesh's job; see Service Mesh.
A proxy that caches
A reverse proxy that stores responses is a shared cache. RFC 9111 defines it: "a cache that stores responses for reuse by more than one user", against a private cache "dedicated to a single user". Its rules are what keep one shopper's page from reaching another:
privatemeans "a shared cache MUST NOT store the response" (section 5.2.2.7).no-storemeans a cache "MUST NOT store any part of either the immediate request or the response" (5.2.2.5).- "A shared cache MUST NOT use a cached response to a request with an Authorization header field" unless the response allows it (3.5).
- A response with
Varyis not reused unless the new request's matching headers agree (4.1), ands-maxageoverridesmax-agefor shared caches (5.2.2.10).
nginx's cache is off until proxy_cache names a zone. Its key defaults to $scheme$proxy_host$request_uri, so changing the key is how you decide what counts as "the same request". It will not cache a response with Set-Cookie or Vary: *. proxy_cache_valid with only a time caches 200, 301 and 302 responses. For a hot key, proxy_cache_lock (off by default) lets one request fill an element while others wait. The wider design is in Cache Layers and Cache Stampede and Hot Keys.
Trade-offs
| Choice | Gain | Cost | Watch for |
|---|---|---|---|
| Reverse proxy in front of apps | One door for TLS, routing, buffering | One more hop and one more failure domain | Header inheritance, timeouts that disagree with the app |
| Terminate TLS at the proxy | Central certificates, cheaper backends | Plaintext inside | Unverified re-encryption (proxy_ssl_verify off) |
| Trust forwarded headers | The app sees the real client | Anyone can forge them | Trust only addresses you control |
Three illustrations
A minimal example is the address chain above. A realistic example is ShopStream's front door: an edge proxy that terminates TLS, routes /api/* to app servers and /images/* to the image origin, as in ShopStream Stage 2, now placed behind a CDN.
A failure or edge case (a spoofed header). A rate limiter keys on the first address in X-Forwarded-For. An attacker sends X-Forwarded-For: 1.2.3.4 and a different fake value on every request. The proxy appends the real address at the end, the limiter reads the first entry, and the attacker never meets a limit. Read from the right, count only trusted hops, or overwrite the header at the first proxy you own.
The other side of this topic. A client sees a reverse proxy only through its effects, which is the point of the design.
In practice: putting a CDN in front of ShopStream's edge
This is a realistic scenario, not a case history. It is a composite on the course's running example, ShopStream. It describes no real company, and every number other than the shared ShopStream figures is an illustrative assumption.
The situation
ShopStream's edge proxy rate-limits by client address (see Rate Limiting) at a few requests per second per address. The team adds a CDN in front of the edge for images and cacheable product data. Every request that reaches the edge now comes from a CDN address.
Starting architecture
Figure 3. The CDN becomes the TCP peer. How to read this: the edge's own socket sees only CDN addresses. The shopper survives only inside the forwarded header, so the limiter must read it, and must read it safely.
Options considered
| Option | Behaviour | Risk |
|---|---|---|
| A. Leave the limiter on the socket address | Many shoppers share a few CDN addresses | The limit hits everyone at once |
B. Key on the first X-Forwarded-For entry |
Looks right in tests | Spoofable (the failure example) |
C. Trust X-Forwarded-For only from the CDN's ranges, read the last untrusted address |
Correct and not spoofable | Ranges must be kept current |
| D. Limit at the CDN instead | One place | Rules live outside your code |
The decision
Option C, with set_real_ip_from for the CDN's published ranges, real_ip_header X-Forwarded-For and real_ip_recursive on as in the snippet above. Record: context shoppers hidden behind CDN addresses; decision trust forwarded addresses only from the CDN; consequences a list of CDN ranges to maintain; revisit when the CDN offers a signed client-address header.
Rollout and verification
- Log both
$remote_addrand$realip_remote_addrbefore limits use the new address. - Test a forged
X-Forwarded-Forthrough the CDN and as a direct hit on the edge; the direct hit must not be trusted. - Alert on requests reaching the edge from outside the CDN ranges.
The 2 a.m. failure
The page reads: a surge of 429 responses for ordinary shoppers on a promotion night. Checks: what address does the limiter key on; what is $remote_addr against $realip_remote_addr; did the CDN publish new ranges, so some of its addresses are no longer trusted and the limiter sees the CDN's address again. Mitigation: add the new ranges or temporarily raise the limit for those sources. Follow-up: refresh the ranges from the provider's published list automatically and alert when the edge sees an untrusted peer.
Architect's notes
- Decide the number of trusted hops first, then configure every hop to match.
- Overwrite, do not trust, at the first proxy you own.
- Verify the backend certificate whenever you re-encrypt.
- Cache only what is the same for everyone, and test with two users.
Figure 4. Trust follows the connection, not the header. How to read this: start with who delivered the request. Only a trusted peer's header is read, and from the right.
Common mistakes
- Using the first
X-Forwarded-Forentry for security decisions. - Trusting forwarded headers from the whole internet.
- Re-encrypting without verifying the backend certificate.
- Caching responses that depend on
Authorizationor a cookie.
Key takeaways
- A forward proxy is chosen by the client and serves clients; a reverse proxy serves servers and is invisible to clients.
- The application loses the client address, scheme and host behind a proxy, and gets them back through
ForwardedorX-Forwarded-*. - Those headers can be forged by anyone; trust only entries written by proxies you control.
- Terminating TLS centralises certificates and leaves a plaintext hop unless you re-encrypt and verify.
- A caching proxy is a shared cache:
private,no-store,AuthorizationandVarydecide what it may reuse.
Practice exercises
Exercise 1 (20 min). The TCP peer is 192.0.2.5 and the header is X-Forwarded-For: 198.51.100.9, 203.0.113.9. Which address is the client if 192.0.2.5 and 203.0.113.9 are both trusted proxies? And if only 192.0.2.5 is trusted?
Exercise 2 (40 min). Write the nginx realip lines for an edge behind two layers of known proxies, and explain the effect of real_ip_recursive.
Exercise 3 (1 hour). Draw the trust boundary of a system you work on: which hops overwrite forwarded headers, which append, which only read.
Related reading
- Load Balancing: layers 4 and 7, algorithms, health checks, TLS termination.
- HTTP/1.1, HTTP/2 and HTTP/3 on the Server: the connections between hops.
- Cache Layers: where a proxy cache fits among the others.
- Rate Limiting: what the client address feeds.
- API Gateway and Service Mesh: proxies that add policy.
- SSRF and the OWASP API Top 10: egress control for server-side requests.
Official documentation
- RFC 9110: HTTP Semantics: intermediaries,
Connection,Via,CONNECT. - RFC 7239: Forwarded HTTP Extension:
for,by,host,proto, security considerations. - RFC 9111: HTTP Caching: shared caches,
private,no-store,Vary,s-maxage. - nginx: proxy module, realip module, SSL module, upstream module.
- Envoy: HTTP header manipulation and
x-forwarded-for.