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, usuallyhttps.X-Forwarded-Host: theHostthe 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');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
};
}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/accountLocation: http://localhost:8443/loginThe user arrived over HTTPS and got sent to http://. Restarted in trust mode:
Location: https://localhost:8443/loginThis 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/whoamix-forwarded-for: 127.0.0.1
leftmost (naive): 127.0.0.1
rightmost from trusted proxy: 127.0.0.1Now 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/whoamix-forwarded-for: 203.0.113.99, 127.0.0.1
leftmost (naive): 203.0.113.99
rightmost from trusted proxy: 127.0.0.1The 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/accountLocation: http://127.0.0.1:8097/loginThe 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.