Ch. 20 · Networking

Reverse Proxies and Trusted Forwarded Headers

Behind a reverse proxy your app sees the wrong scheme and client IP. Learn to read X-Forwarded-* safely, with a spoofing test you can run.

~6 min readintermediateupdated Oct 11, 2026

Put a load balancer or reverse proxy in front of an application and three facts the app used to know quietly become wrong. The client’s IP address is now the proxy’s. The scheme is http, because the proxy terminated TLS and connected onward in plain text. Sometimes even the hostname is an internal one. Proxies fix this by adding headers that describe the original request, and the app has to decide whether to believe them.

Interviewers ask about this because it produces bugs that look unrelated: redirects to http://, login loops, rate limiters that block everyone at once, and audit logs full of made-up IP addresses. This note builds a TLS-terminating proxy and an app in Node and runs each failure.

What the app loses behind a proxy

Without a proxy, the app reads everything from the connection. req.socket.remoteAddress is the client, and the connection is either TLS or it isn’t. Behind a proxy, the connection belongs to the proxy, so the original details travel in headers:

  • X-Forwarded-For: the client address, with each proxy appending the address it received the request from, giving a comma-separated chain.
  • X-Forwarded-Proto: the scheme the client used, usually https.
  • X-Forwarded-Host: the Host the client asked for.

RFC 7239 standardised the same information in one header, Forwarded: for=203.0.113.7;proto=https;host=shop.example, but the X-Forwarded-* versions are still what most load balancers send.

The catch is that these are ordinary request headers. A client can send them too.

A proxy and an app to experiment with

The proxy accepts HTTPS on port 8443 and forwards plain HTTP to the app. It appends the client’s address to X-Forwarded-For and sets the scheme and host:

https.createServer({ key: readFileSync('key.pem'), cert: readFileSync('cert.pem') }, (req, res) => {
  const clientIp = req.socket.remoteAddress.replace('::ffff:', '');
  const prior = req.headers['x-forwarded-for'];
  const headers = {
    ...req.headers,
    'x-forwarded-for': prior ? `${prior}, ${clientIp}` : clientIp, // append, never replace blindly
    'x-forwarded-proto': 'https',
    'x-forwarded-host': req.headers.host,
  };
  const upstream = http.request({ host: '::1', port: 8097, path: req.url, method: req.method, headers }, (up) => {
    res.writeHead(up.statusCode, up.headers);
    up.pipe(res);
  });
  upstream.on('error', () => { res.writeHead(502); res.end(); });
  req.pipe(upstream);
}).listen(8443, '127.0.0.1');
JavaScript

The app redirects /account to the login page and reports what it thinks the client IP is. It only believes forwarded headers when started in trust mode and when the connection comes from the proxy’s address. (On one laptop everything is loopback, so the proxy connects over IPv6 ::1 and clients use IPv4, which keeps the two distinguishable.)

const TRUSTED_PROXIES = new Set(['::1']); // the proxy connects over IPv6 loopback in this demo
const trustHeaders = process.argv[2] === 'trust';

function clientInfo(req) {
  const peer = req.socket.remoteAddress.replace('::ffff:', '');
  const fromProxy = trustHeaders && TRUSTED_PROXIES.has(peer);
  const hops = (req.headers['x-forwarded-for'] ?? '').split(',').map((s) => s.trim()).filter(Boolean);
  return {
    proto: fromProxy ? req.headers['x-forwarded-proto'] ?? 'http' : 'http',
    host: fromProxy ? req.headers['x-forwarded-host'] ?? req.headers.host : req.headers.host,
    naiveIp: hops[0] ?? peer,                       // leftmost entry: whatever the client claimed
    ip: fromProxy ? hops.at(-1) ?? peer : peer,     // rightmost entry: added by our proxy
  };
}
JavaScript

The wrong scheme in redirects

With the app ignoring forwarded headers, a request through the proxy:

curl -s --cacert cert.pem -o /dev/null -w 'Location: %{redirect_url}\n' https://localhost:8443/account
Terminal
Location: http://localhost:8443/login
Text

The user arrived over HTTPS and got sent to http://. Restarted in trust mode:

Location: https://localhost:8443/login
Text

This one detail causes a family of production bugs. If the load balancer redirects all HTTP to HTTPS, the user bounces between the two until the browser gives up with “too many redirects”. Frameworks that decide whether a request is secure from the connection will refuse to set Secure cookies: express-session, for example, silently skips a secure: true session cookie when it thinks the request is plain HTTP, so logins appear to succeed and then forget the user. OAuth callbacks built from the request URL come out as http:// and fail to match the registered redirect URI.

Reading X-Forwarded-For without being fooled

An honest client through the proxy:

curl -s --cacert cert.pem https://localhost:8443/whoami
Terminal
x-forwarded-for: 127.0.0.1
leftmost (naive): 127.0.0.1
rightmost from trusted proxy: 127.0.0.1
Text

Now a client that adds its own header, claiming to be 203.0.113.99:

curl -s --cacert cert.pem -H 'X-Forwarded-For: 203.0.113.99' https://localhost:8443/whoami
Terminal
x-forwarded-for: 203.0.113.99, 127.0.0.1
leftmost (naive): 203.0.113.99
rightmost from trusted proxy: 127.0.0.1
Text

The proxy did the right thing and appended the real address. But code that takes the first entry, which is what most “get client IP” snippets do, believes the client’s lie. Rate limiting by IP, IP allowlists, fraud scoring and audit logs all break the same way: an attacker sends a different made-up address with every request.

The reliable rule is to read the chain from the right. Each of your own proxies appends one entry, so if you run one proxy, the last entry is the address that proxy saw. With a CDN in front of a load balancer, skip the hops that belong to your infrastructure; the first address from the right that isn’t yours is the client. Anything to the left of that is a claim you can’t verify.

This is where a well-known framework setting bites. In Express, app.set('trust proxy', true) makes req.ip the leftmost address, the one the client controls. Setting it to the number of proxy hops (1) or to your proxies’ subnets makes it count from the right instead.

Trust depends on who connected

Forwarded headers are only meaningful when your proxy wrote them. Here’s a client skipping the proxy and calling the app’s port directly with forged headers:

curl -s -H 'X-Forwarded-Proto: https' -H 'X-Forwarded-Host: bank.example' \
  -o /dev/null -w 'Location: %{redirect_url}\n' http://127.0.0.1:8097/account
Terminal
Location: http://127.0.0.1:8097/login
Text

The app ignored the forged scheme and host because the connection didn’t come from a trusted proxy address. An app that trusted those headers from anyone would have redirected to https://bank.example/login, and if a cache stored that response, other users would get the poisoned redirect. The IP check behaved the same way: the forged X-Forwarded-For showed up in the leftmost slot, but the app used the real peer address.

Two layers protect you here. Network rules should make the app reachable only from the proxy (a security group, a private subnet, or binding to an internal interface). And the app’s trust configuration should name the proxy’s addresses, so a mistake in the network layer doesn’t turn into header spoofing.

Interview practice

Your team moves TLS termination from the app servers to a new load balancer. Afterwards: users get stuck in a redirect loop on login; the login rate limiter starts locking out everyone at once; and after someone sets trust proxy to true to fix the rate limiter, attackers start getting around it. Explain each problem and the right configuration.

Answer

The redirect loop comes from scheme detection. The app now receives plain HTTP from the load balancer, believes the request is insecure, and redirects to its HTTPS URL (or builds an http:// redirect), which the load balancer sends back over HTTPS, and round it goes. The app has to read X-Forwarded-Proto, but only from the load balancer.

The rate limiter locked out everyone because it was keying on the socket address, and every request now comes from the load balancer’s handful of IPs. All users share one bucket.

Setting trust proxy to true fixed the first two but made Express use the leftmost X-Forwarded-For entry, which clients control, so attackers rotate fake addresses and get a fresh rate-limit bucket each time. The right setting is the hop count or the load balancer’s subnets, so the client IP is the entry the load balancer appended. I’d also make sure the app servers accept traffic only from the load balancer’s security group, and check whether the load balancer itself overwrites or appends incoming X-Forwarded-For, because that decides how many entries from the right to skip.

Further reading

More in Networking

read ✓Networking · mid

Networking: L4 vs L7 Load Balancing

Build a tiny L4 and L7 balancer in Node, watch keep-alive pin every request to one backend, and learn what each layer can route on.

~6 min readread →
read ✓Networking · mid

Networking: HTTP Redirects

Test 301, 302, 303, 307 and 308 against a local Node server with curl and fetch, and see which codes turn a POST into a GET and why.

~6 min readread →
read ✓Networking · mid

HTTP Connection Reuse and Latency

Measure what keep-alive saves, size a client connection pool with Little's law, and retry safely when a pooled socket was closed under you.

~6 min readread →
esc