Skip to content

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

curl -v --resolve api.example.com:443:203.0.113.44 https://api.example.com/health

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.

Cache-Control: public, max-age=31536000, immutable

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.

Cache-Control: private, no-cache

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.

Cache-Control: no-store

Revalidation is where ETag earns its place:

HTTP/1.1 200 OK
ETag: "9f3c1a5e"
Cache-Control: no-cache

The client stores that tag and includes it next time:

GET /profile HTTP/1.1
If-None-Match: "9f3c1a5e"
HTTP/1.1 304 Not Modified
ETag: "9f3c1a5e"

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:

Vary: Accept-Encoding, Authorization

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-cache means revalidate. no-store means don't keep it. private means 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:

HTTP/1.1 200 OK
Content-Length: 1256
Connection: keep-alive

When the length isn't known in advance — a generated stream, a large export — chunked encoding replaces it:

HTTP/1.1 200 OK
Transfer-Encoding: chunked

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:

Access-Control-Allow-Origin: https://app.example.com

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

  1. Fetch a static asset from a large site twice with curl -v and find the caching headers. Then repeat with -H 'If-None-Match: "<the etag>"' and confirm you get a 304.
  2. 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 forged X-Forwarded-For from curl and see what gets logged.
  3. Find a response in your own stack that sets no-cache where it means no-store, or one that omits Vary while varying by Authorization.
  4. 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.
  5. Add a custom header to that fetch, watch the preflight appear in the network tab, and make the OPTIONS request fail deliberately. Note that the PUT never 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