Headers, caching, and what proxies rewrite
A request that passes through a CDN, a load balancer, and a reverse proxy arrives at your application looking nothing like what the client sent. The source address is wrong, the scheme may be wrong, and whether the response gets cached for a year or not at all was decided by a header somebody set without much thought.
Four groups of headers cause nearly all the confusion, and each one has a specific production failure attached to it.
Host: one address, many sites
The Host header is what makes virtual hosting possible. A single server on a single IP serves dozens of sites, and the only thing distinguishing a request for one from a request for another is that header — which is why it became mandatory in HTTP/1.1 and why a request without it gets a 400.
Two consequences worth carrying:
Testing by IP address doesn't test what you think. curl http://203.0.113.44/ sends Host: 203.0.113.44, which matches no virtual host, so you get the server's default site — often a different application entirely. The --resolve flag above is the right tool: it connects to the address you name while sending the Host you name, which is how you test a specific backend behind a load balancer.
Its encrypted cousin is SNI. Host is inside the encrypted request, so it can't help a server choose a certificate — that decision happens during the handshake, before any HTTP exists, using the SNI extension. Normally they match. When they don't, you get a certificate for one site and content from another, which is a genuinely confusing thing to debug and is covered in the TLS failures article.
Where did this request come from, really?
Behind a proxy, the source address your application sees is the proxy's. Every client looks like it came from 10.20.0.7. Rate limiting by IP limits the proxy; geolocation places every user in your own datacentre; audit logs record nothing useful.
The de facto solution is a header the proxy adds:
X-Forwarded-For: 203.0.113.9, 198.51.100.4
X-Forwarded-Proto: https
X-Forwarded-Host: api.example.com
X-Forwarded-For is a list: the original client first, then each proxy that forwarded it. X-Forwarded-Proto matters more than it looks — an application behind a TLS-terminating proxy receives plain HTTP and, without this header, will happily build http:// redirect URLs and set cookies without the Secure flag, downgrading users who arrived over HTTPS. RFC 7239 standardised all of this into a single Forwarded header, though the X- versions remain far more widely deployed.
In nginx, the proxy side looks like this:
location / {
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
$proxy_add_x_forwarded_for appends the immediate peer's address to any existing header value rather than replacing it, which is what preserves the chain.
A client can send X-Forwarded-For too
It's an ordinary header. Anyone can put anything in it — including an address that isn't theirs, chosen specifically to defeat a rate limit or an IP allowlist.
Trust it only when your own proxy overwrote it, and only from proxies you control. In nginx that means configuring set_real_ip_from <proxy-address> with real_ip_header X-Forwarded-For, so the address is taken from the header only when the connection came from a known proxy. Application frameworks have an equivalent setting (often "trusted proxies") and it is off, or wrong, by default in more of them than you'd like.
An IP allowlist that reads an unvalidated X-Forwarded-For is not an allowlist. It's a suggestion box.
How long may this be cached, and by whom
Cache-Control is the header that decides, and its directives split into two questions: who may store the response, and for how long.
That's for a fingerprinted static asset — app.4f3a9b.js, whose name changes when its contents change. public allows shared caches (CDNs, proxies) to store it, max-age is a year in seconds, and immutable tells the browser not to even revalidate it. Safe precisely because a new build produces a new filename.
That's for a personalised HTML page. private forbids shared caches from storing it — the header that stops a CDN from serving one user's account page to another, which is a real and spectacular class of incident. no-cache is the most misread directive in HTTP: it does not mean "don't cache." It means "cache it, but revalidate before every use." The directive that actually forbids storage is no-store.
Revalidation is where ETag earns its place:
The client stores that tag and includes it next time:
A 304 with no body. The server still did the work of determining the content hadn't changed, but the bytes never crossed the network — which on a mobile connection is most of the cost.
The header that prevents the cross-user leak in a subtler way is Vary:
It tells caches that responses differ by those request headers, so a cached copy may only be served to a request whose values match. Omitting Vary: Authorization on a response that depends on who's logged in is exactly how a shared cache serves the wrong user's data — and it looks fine in every test where only one user is logged in.
no-cachemeans revalidate.no-storemeans don't keep it.privatemeans not in a shared cache. Getting these three confused is how personal data ends up on a CDN.
Connection reuse, and why Content-Length matters
HTTP/1.1 keeps the TCP connection open by default so the next request skips the handshake and TLS negotiation — a saving of at least one round trip, often three. For that to work, the client has to know exactly where one response ends and the next begins, which is what Content-Length provides:
When the length isn't known in advance — a generated stream, a large export — chunked encoding replaces it:
Each chunk is prefixed with its size, and a zero-length chunk ends the body.
The operational trap lives in the timeouts. Every hop has its own idle timeout for keep-alive connections, and when a client's pool holds a connection longer than the proxy will, the proxy closes it and the client's next request lands on a socket that's already gone — surfacing as connection reset by peer, intermittently, under no particular load. The fix is making the client's idle timeout shorter than the server's, not longer.
CORS: a browser rule, not a network one
Access to fetch at 'https://api.example.com/data' from origin 'https://app.example.com'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present
on the requested resource.
Two things about this error are true and consistently misunderstood.
The request usually succeeded. The server received it, processed it, and replied. The browser then refused to hand the response to JavaScript because the response lacked permission. curl fetching the same URL works perfectly, which is why "it works in curl but not in the browser" is the signature of a CORS problem rather than a network one.
It's enforced by browsers only. CORS protects users from a page on one origin reading data from another using their credentials. It is not access control, and it stops nothing that isn't a browser — mobile apps, servers, and scripts ignore it entirely. Treating Access-Control-Allow-Origin as a security boundary is a mistake; real authorisation still has to happen on the server.
The permission is a response header:
For anything other than a simple GET — a PUT, a DELETE, or a request with a custom header — the browser first sends a preflight:
OPTIONS /data HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: authorization, content-type
and expects a response naming what's allowed:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, PUT, POST, DELETE
Access-Control-Allow-Headers: authorization, content-type
Access-Control-Max-Age: 86400
Which produces a distinctive failure: GET works and PUT doesn't, because nothing in the stack answers OPTIONS. Reverse proxies that forward only certain methods, and application routers that never registered an OPTIONS handler, both cause it. Access-Control-Max-Age lets the browser cache the preflight result — without it, every single PUT costs two round trips.
The wildcard deserves a warning of its own. Access-Control-Allow-Origin: * cannot be combined with credentials: a browser refuses to send cookies to a wildcard origin. And responding with * on an internal API is a habit that survives into production more often than it should.
Practice
- Fetch a static asset from a large site twice with
curl -vand find the caching headers. Then repeat with-H 'If-None-Match: "<the etag>"'and confirm you get a304. - Put nginx in front of a small application that logs the client address. Compare the logged address with and without
proxy_set_header X-Forwarded-For, then send a forgedX-Forwarded-Forfromcurland see what gets logged. - Find a response in your own stack that sets
no-cachewhere it meansno-store, or one that omitsVarywhile varying byAuthorization. - Build a page on one origin that fetches from another with no CORS headers. Confirm the request reaches the server (check its access log) while the browser blocks the response, and explain that difference in one sentence.
- Add a custom header to that fetch, watch the preflight appear in the network tab, and make the
OPTIONSrequest fail deliberately. Note that thePUTnever happens.
Exercise 2 is the one with a security lesson in it. Once you've watched your own log record an address a client invented, "the request came from this IP" stops being something you accept without asking which header it came from and who was allowed to set it.
Everything across these four articles has described a single request and response on a single connection. What none of them touched is the connection itself as a bottleneck — HTTP/1.1 has real, well-documented limits on how much work one connection can do at a time, and HTTP evolution is the story of removing them.
Sources
- IETF, RFC 9110 – HTTP Semantics
- IETF, RFC 9111 – HTTP Caching
- IETF, RFC 7239 – Forwarded HTTP Extension
- WHATWG, Fetch Standard — the specification that defines CORS behaviour.
- nginx, Module ngx_http_realip_module