Skip to content
ansezz.

▸ Free tool

HTTP Headers.

Every request and response header that earns its place in production — what it does, a value you would really see on the wire, and the trap that comes with it. Type in the box to filter the whole reference.

▸ Static page

The whole reference is server-rendered HTML. The only script here is the filter box, and it never sends anything anywhere.

Caching and revalidation

Caching headers are the highest-leverage lines of config you will write all quarter. A correct Cache-Control on a hot endpoint removes more load than a week of query tuning, and a wrong one serves the wrong body to the wrong person from a machine you do not operate.

Two mental models make the rest of this section obvious. First, freshness is measured at the origin, not at you: a response with max-age=600 that has been sitting in a CDN for nine minutes has sixty seconds left, and Age is how you find that out. Second, every response is stored under a key, and that key is the URL plus whatever Vary says. Everything that goes wrong with caches is one of those two facts being ignored.

Caching and revalidation headers
Header What it does Example
Cache-Control Request + response

The entire caching policy in one field: how long a response stays fresh, who is allowed to store it, and what has to happen once it goes stale.

The same word means different things on a request and on a response. Every directive is spelled out in the next table.

Cache-Control: public, max-age=60, s-maxage=86400
Age Response

Seconds this response has already spent sitting in caches. Shared caches add it; clients subtract it from max-age to get the remaining freshness.

An Age that climbs past your max-age without a revalidation is a misconfigured CDN, not a fast one.

Age: 118
Expires Response Legacy

An absolute date after which the response is stale. The HTTP/1.0 way of saying max-age.

Ignored whenever Cache-Control max-age or s-maxage is present. Expires: 0 is the widespread non-conforming way to say 'already stale' — use no-cache instead.

Expires: Wed, 12 Aug 2026 09:14:00 GMT
ETag Response

An opaque version tag for this exact representation. The client hands it back to ask whether it is still current.

A W/ prefix marks it weak: fine for cache revalidation, useless for If-Match concurrency. Gzip and identity variants must not share a strong ETag.

ETag: "3f8a1c-1a4"
Last-Modified Response

When the representation last changed. The weaker validator, for when you have no ETag.

One-second resolution, so two edits inside the same second are invisible. Also what caches use for heuristic freshness when you send no explicit lifetime.

Last-Modified: Tue, 11 Aug 2026 22:03:11 GMT
If-None-Match Request

Revalidation: send the body only if the ETag is none of these. A match gets a 304 with no body.

Wins over If-Modified-Since when both are sent. Uses weak comparison, so W/ tags do match.

If-None-Match: "3f8a1c-1a4"
If-Modified-Since Request

Date-based revalidation: send the body only if it changed after this timestamp.

Only meaningful on GET and HEAD, and ignored entirely when If-None-Match is present.

If-Modified-Since: Tue, 11 Aug 2026 22:03:11 GMT
Vary Response

Names the request headers that were used to select this representation. In other words: the rest of the cache key.

Omit one and a shared cache will happily hand a Brotli body to a client that cannot decode it, or one tenant's CORS headers to another. Vary: * means never reusable.

Vary: Accept-Encoding, Origin
CDN-Cache-Control Response

Caching policy aimed at CDNs only. They obey it in place of Cache-Control; browsers ignore it completely.

RFC 9213 targeted cache control. The clean way to cache hard at the edge and barely at all in the browser, without the usual s-maxage contortions.

CDN-Cache-Control: max-age=31536000
Surrogate-Control Response De facto

The pre-RFC vendor spelling of the same idea, still honoured by Varnish, Fastly and friends.

Surrogate-Control: max-age=86400
Cache-Status Response

How each cache in the path handled the request: hit or miss, remaining TTL, which node answered.

RFC 9211. Standardises the pile of X-Cache headers every CDN invented separately.

Cache-Status: "cdn"; hit; ttl=284
Pragma Request Legacy

The HTTP/1.0 ancestor of Cache-Control, with one useful value.

Only ever defined for requests. Sending it on a response does nothing except make your headers longer.

Pragma: no-cache
Warning Request + response Dead

Free-text notes about staleness and transformations.

Obsoleted by RFC 9111. Nothing generates it and nothing reads it.

Warning: 110 - "Response is Stale"

Cache-Control, directive by directive

Almost every caching argument I have sat through was really an argument about one of these words. They combine, they contradict each other, and several of them mean different things depending on which side of the exchange they appear on.

Cache-Control directives
Directive What it does Example
max-age=N Request + response

On a response: fresh for N seconds, counted from when the origin generated it — not from when you received it. On a request: I will not accept anything older than N seconds.

Age is what makes the origin-relative part work. A response that spent 500s in a CDN has 100s of freshness left, not 600.

Cache-Control: max-age=600
s-maxage=N Response

Freshness for shared caches only — CDN, reverse proxy. Overrides both max-age and Expires for them, and leaves the browser on max-age.

Also implies proxy-revalidate, so a shared cache may not serve it stale once it expires.

Cache-Control: max-age=60, s-maxage=86400
no-cache Request + response

Store it, but never reuse it without asking the origin first. This is a revalidation rule, not a ban on caching.

A 304 costs one small round-trip and no body. This is usually the right answer for HTML.

Cache-Control: no-cache
no-store Request + response

Do not write this to any storage, anywhere, in any form. The only directive that actually means do not cache.

Correct for responses carrying credentials or personal data. Add private and no-cache too if you need to defeat ancient, buggy intermediaries.

Cache-Control: no-store
private Response

Only a single-user cache — the browser — may store this. Shared caches must not.

Not a security control. It is a request to well-behaved caches, and the body still passes in cleartext through anything terminating your TLS.

Cache-Control: private, no-store
public Response

Storable even in the cases where it normally would not be — most usefully, when the request carried an Authorization header.

Redundant on an ordinary unauthenticated GET; that is already cacheable. Adding it out of habit to authenticated responses is how private data lands in a CDN.

Cache-Control: public, max-age=3600
must-revalidate Response

Once stale, it must not be served again until the origin confirms it.

If the origin is unreachable the cache must return a 504 rather than a stale body. That is a correctness choice, and it costs you availability.

Cache-Control: max-age=60, must-revalidate
proxy-revalidate Response

must-revalidate, but binding only on shared caches.

Cache-Control: max-age=600, proxy-revalidate
immutable Response

The body at this URL will never change, so do not revalidate it even when the user hits reload.

Only safe on content-hashed URLs like app.7f3c1d.js. Put it on /app.js and you will serve last week's bundle to people who have no way to fix it.

Cache-Control: max-age=31536000, immutable
stale-while-revalidate=N Response

For N seconds past expiry, serve the stale copy immediately and refresh it in the background.

The largest latency win available in this whole field, for one line of config. The cost is that some users see a response up to N seconds out of date.

Cache-Control: max-age=60, stale-while-revalidate=600
stale-if-error=N Response

If the origin errors or times out, keep serving the stale copy for up to N seconds instead of passing the failure through.

Free availability during an incident, and nothing to build. Note that must-revalidate cancels it.

Cache-Control: max-age=60, stale-if-error=86400
no-transform Response

Intermediaries must not recompress images, minify, or otherwise rewrite the payload.

Worth setting on anything signed, checksummed, or byte-exact — and on images where a carrier proxy would otherwise degrade them.

Cache-Control: no-transform
must-understand Response

Store this only if the cache understands the caching rules for its status code.

Newer caches honour must-understand and may store; older ones see the no-store beside it and skip. A forward-compatibility trick, not an everyday directive.

Cache-Control: must-understand, no-store
only-if-cached Request

Return the cached copy or a 504. Do not touch the network.

How offline modes and some service workers ask for a strictly local answer.

Cache-Control: only-if-cached
max-stale=N Request

I will accept a response that has been stale for up to N seconds.

Cache-Control: max-stale=300
min-fresh=N Request

I want a response that will still be fresh for at least another N seconds.

Cache-Control: min-fresh=60

▸ Classic mistake #1

no-cache does not mean do not cache

no-cache means store it and revalidate before every reuse. The cache keeps a copy, sends a conditional request with the ETag, and usually gets back a 304 with no body at all. That is a fast, cheap, correct outcome and it is exactly what you want on HTML.

The directive that means what people think no-cache means is no-store. If a response contains a session token, an invoice, or someone's address, that is the one you need — and it should be paired with private so an old intermediary that misreads one of them still gets the message.

▸ Classic mistake #2

A missing Vary poisons the edge

You gzip based on Accept-Encoding. You pick a language from Accept-Language. You echo the caller's Origin into Access-Control-Allow-Origin. Each of those makes the response depend on a request header — and if that header is not in Vary, the cache stores one variant under a key that does not mention it.

Then a different client asks, gets a hit, and receives a Brotli body it cannot decode, a French page it cannot read, or another tenant's CORS grant. It looks random, it is not reproducible locally, and it is one header. Audit this whenever a response body depends on anything except the URL.

Conditional requests and ranges

The validators in the previous section save bandwidth. The ones here save correctness. If-Match on a PUT or PATCH turns a blind overwrite into a compare-and-swap: the server accepts the write only while the ETag you read still matches, and returns 412 otherwise. That is the difference between two tabs merging and two tabs silently destroying each other's edits.

Ranges are the other half. Range plus If-Range is how video seeking and resumable downloads work: ask for the bytes you are missing, and let the server hand back the whole file instead if it changed underneath you. Your static file server and CDN already implement all of this; the reason to know it is so you can tell whether a 206 in your logs is a feature or an attack.

Conditional request and range headers
Header What it does Example
If-Match Request

Only perform this write if the resource still carries one of these ETags. HTTP's compare-and-swap.

A mismatch returns 412 Precondition Failed. Strong comparison, so a W/ tag never matches. This is how you stop two tabs silently overwriting each other.

If-Match: "3f8a1c-1a4"
If-Unmodified-Since Request

The dated version: only write if nothing has changed since this timestamp.

Weaker than If-Match because of the one-second clock. Prefer ETags whenever you can generate them.

If-Unmodified-Since: Tue, 11 Aug 2026 22:03:11 GMT
Range Request

Ask for specific byte ranges instead of the whole body.

Answered with 206 and a Content-Range, or 416 if the range makes no sense. Multi-range requests are legal and mostly a denial-of-service vector — cap them.

Range: bytes=0-1048575
Accept-Ranges Response

Advertises that range requests work here. Two values in practice: bytes, or none.

Without it, well-behaved download managers will not attempt a resume.

Accept-Ranges: bytes
Content-Range Response

On a 206: which bytes these are, and how large the whole thing is.

An unsatisfiable range gets a 416 plus Content-Range: bytes */7340032 so the client learns the real size.

Content-Range: bytes 0-1048575/7340032
If-Range Request

Send the range only if the resource has not changed; otherwise send the whole thing.

Saves a round-trip on a resumed download: you get a 206 if the file is untouched, a plain 200 if it is not, and never a corrupted splice.

If-Range: "3f8a1c-1a4"

Content negotiation and payload

This group describes the body: what format it is in, how it was compressed, how long it is, and what the client would have preferred. Most APIs use about a third of it and ignore the rest, which is a defensible choice — the trouble starts when you half-implement negotiation and forget the Vary that makes it safe behind a cache.

Two distinctions are worth burning in. Content-Encoding is end-to-end compression that the client decodes; Transfer-Encoding is per-hop framing that any proxy may undo. And Content-Location is not Location: one tells you where the representation you just got actually lives, the other tells the client to go somewhere else.

Content negotiation and payload headers
Header What it does Example
Accept Request

Media types the client can handle, with q-weights expressing preference.

If nothing matches, the honest answer is 406. Most APIs ignore this and always return JSON, which is fine — just do not also advertise negotiation you do not do.

Accept: application/json;q=1.0, text/plain;q=0.5, */*;q=0.1
Accept-Encoding Request

Compression formats the client understands.

The reason Vary: Accept-Encoding is mandatory on anything you compress at the origin.

Accept-Encoding: gzip, br, zstd
Accept-Language Request

Preferred human languages, weighted.

Serving different copy per language without Vary: Accept-Language is the classic way to show a French page to an English visitor straight out of the cache.

Accept-Language: en-GB, en;q=0.9, fr;q=0.7
Accept-Charset Request Dead

Character encodings the client accepts.

Deprecated in RFC 9110. Browsers stopped sending it years ago, and everything is UTF-8.

Accept-Charset: utf-8
Content-Type Request + response

The media type of the body, plus parameters such as charset and multipart boundary.

Always send charset on text types. A JSON body sent with no Content-Type is the single most common cause of a mysterious 415.

Content-Type: application/json; charset=utf-8
Content-Encoding Response

How the body was compressed. End-to-end: it stays compressed until the client decodes it.

Not the same thing as Transfer-Encoding, which is per-hop and can be undone by any proxy on the path.

Content-Encoding: br
Content-Language Response

Which language the body is in — the answer to Accept-Language, and one of the seven headers CORS exposes by default.

Content-Language: en-GB
Content-Length Request + response

Body size in bytes.

Sending Content-Length and Transfer-Encoding: chunked on the same HTTP/1.1 message is the foundation of request smuggling. A front end should reject that message, not pick a winner.

Content-Length: 34871
Content-Disposition Response

Render inline or download, and what to call the file.

For non-ASCII names use the filename* form with UTF-8 percent-encoding. Sanitise it either way — an unescaped filename is a header-injection hole.

Content-Disposition: attachment; filename="q3-report.csv"
Content-Location Response

The direct URL of the representation you just received, when it differs from the URL you asked for.

Not Location, and it redirects nothing. Mixing the two up produces bugs that survive code review because the names look alike.

Content-Location: /reports/q3.json
Content-Digest Request + response

A checksum of the body, carried as a structured field.

RFC 9530, replacing the old Digest header. Useful on webhooks and uploads; compare in constant time when it feeds a signature check.

Content-Digest: sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=:
Transfer-Encoding Request + response

How the message body was framed on this hop — in practice, chunked.

HTTP/1.1 only. Forbidden in HTTP/2 and HTTP/3, which do their own framing.

Transfer-Encoding: chunked
TE Request

Transfer codings the client accepts on this hop, including trailer fields.

Hop-by-hop, and one of the fields a smuggling-aware proxy should normalise rather than forward blindly.

TE: trailers
Allow Response

The methods this resource supports. Required on a 405.

A 405 without Allow leaves the caller guessing, which defeats the entire point of the status code.

Allow: GET, HEAD, PUT, OPTIONS
Location Response

Where to go next: the redirect target on a 3xx, or the URL of the thing you just created on a 201.

A 201 without a Location is a half-finished API. So is a 202 that gives you nowhere to poll.

Location: /orders/1f9c2b
Link Response

Typed relations to other URLs: pagination, preload, canonical, API discovery.

Paired with a 103 Early Hints response, rel=preload buys you asset fetches while the origin is still assembling the real answer.

Link: </orders?page=3>; rel="next", </app.css>; rel=preload; as=style

CORS and the preflight

CORS is a browser mechanism for relaxing the same-origin policy, and nearly all of the confusion around it comes from reading it backwards. The default is that a page may send a cross-origin request but may not read the response. CORS headers are the origin server saying "this particular caller is allowed to read me". They do not gate the request; they gate the reply.

Anything beyond a simple GET or form-shaped POST triggers a preflight: a separate OPTIONS request carrying Origin and Access-Control-Request-Method, plus Access-Control-Request-Headers if you set anything non-safelisted. The browser will not send the real request until that comes back approved, which is why one extra custom header can double your request count and add a round-trip to every call. Raise Access-Control-Max-Age and most of that cost goes away.

CORS request and response headers
Header What it does Example
Origin Request

Which origin the request came from. The browser sets it and page JavaScript cannot change it.

Sent on every cross-origin request and on same-origin POST too. It can legitimately be the literal string null — sandboxed iframes, some redirects — so never let null pass an allowlist check.

Origin: https://app.example.com
Access-Control-Request-Method Request

On a preflight: the method the real request is about to use.

Only ever appears on the OPTIONS preflight, never on the request that follows it.

Access-Control-Request-Method: PATCH
Access-Control-Request-Headers Request

On a preflight: the non-safelisted headers the real request wants to send.

This is why adding one custom header to a fetch call suddenly doubles your request count.

Access-Control-Request-Headers: authorization, content-type
Access-Control-Allow-Origin Response

The single origin allowed to read this response — or the wildcard.

You may send one origin, never a list. Check the request Origin against your allowlist, echo it back, and add Vary: Origin the moment you do.

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

Preflight answer: which methods are permitted.

Only read on the preflight response. Sending it on real responses is harmless noise.

Access-Control-Allow-Methods: GET, POST, PATCH, DELETE
Access-Control-Allow-Headers Response

Preflight answer: which request headers are permitted.

The wildcard does not cover Authorization. If your clients send a bearer token, you have to name that header explicitly or the preflight fails.

Access-Control-Allow-Headers: Authorization, Content-Type, X-Request-Id
Access-Control-Allow-Credentials Response

Lets the browser attach cookies and expose the response when the request used credentials: include.

Illegal alongside a wildcard origin. Turn credentials on and every wildcard in the CORS set stops behaving as a wildcard.

Access-Control-Allow-Credentials: true
Access-Control-Expose-Headers Response

Which response headers JavaScript is allowed to read.

Without it, fetch sees only seven safelisted headers: Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Last-Modified, Pragma. Your custom header is visible in DevTools and absent in code.

Access-Control-Expose-Headers: RateLimit, X-Request-Id, Content-Digest
Access-Control-Max-Age Response

How long the browser may cache this preflight result, in seconds.

The default is 5 seconds and browsers clamp the maximum — Chromium at 7200, Firefox at 86400. Anything larger is silently reduced, not honoured.

Access-Control-Max-Age: 600
Timing-Allow-Origin Response

Lets a cross-origin page read detailed timings and Server-Timing for this resource.

Nothing to do with access control; it only unlocks measurement. Without it, cross-origin Resource Timing entries are mostly zeroes.

Timing-Allow-Origin: https://app.example.com

▸ Classic mistake #3

CORS is not a security boundary

A permissive Access-Control-Allow-Origin does not open your API to attackers, and a strict one does not protect it. Every request a browser blocks on CORS grounds still reaches your server and still runs; the browser only refuses to hand the response back to the script. And curl, a proxy, a mobile app and every server-side HTTP client ignore the whole mechanism, because CORS is enforced by browsers, not by servers.

So: authorise every request on its own merits, and treat CORS as what it is — a rule about which web origins may read your responses. Two things there do carry real weight. Reflecting any Origin you are handed while also sending Access-Control-Allow-Credentials: true hands every site on the internet a session-authenticated read of your API. And an allowlist that matches on endsWith lets evil-example.com through when you meant example.com. Compare full origins, exactly.

Security headers

There are four of these you should ship on every HTML response before you argue about anything else: Strict-Transport-Security, X-Content-Type-Options: nosniff, Referrer-Policy, and a Content-Security-Policy with frame-ancestors. That set costs nothing, breaks nothing, and removes protocol downgrades, MIME-sniffing XSS, URL leakage through the referrer, and clickjacking.

The CSP is the one that takes real work, and the only part that actually stops cross-site scripting is a nonce or hash on script-src. A policy containing 'unsafe-inline' is a compliance artifact, not a defence. Roll a new one out through Content-Security-Policy-Report-Only first, watch the reports for a week of real traffic, then enforce. On the request side, the Sec-Fetch- family is the sleeper: browsers set it, pages cannot forge it, and comparing three strings gives you CSRF protection that needs no token and no session state.

Security headers
Header What it does Example
Strict-Transport-Security Response

Forces HTTPS for this host for max-age seconds, with no click-through past certificate errors.

Ignored over plain HTTP by design. includeSubDomains covers hosts you forgot about; preload bakes you into browser binaries and is genuinely hard to undo. Ship a short max-age first.

Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
Content-Security-Policy Response

An allowlist of where scripts, styles, images, frames and connections are allowed to come from.

The nonce or hash on script-src is the part that actually stops XSS. Leave 'unsafe-inline' in and the policy is decoration. Add base-uri or an injected base tag reroutes every relative script URL.

Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-r4nd0m'; frame-ancestors 'none'; base-uri 'none'
Content-Security-Policy-Report-Only Response

The same policy, enforced nowhere, violations reported.

Always run a new policy here first. Real traffic breaks a CSP in ways staging never will — analytics snippets, injected extensions, a marketing tag someone added last quarter.

Content-Security-Policy-Report-Only: default-src 'self'; report-to csp
X-Content-Type-Options Response

Stops the browser guessing a media type that disagrees with your Content-Type.

One value, no downside, kills a whole class of upload-to-XSS. It also makes browsers refuse scripts and stylesheets served with the wrong type, which is how you find your broken MIME config.

X-Content-Type-Options: nosniff
Referrer-Policy Response

How much of the current URL is attached to outgoing requests.

That value is already the browser default. Set it anyway, and use no-referrer on any page whose URL contains a token, an invite code, or a customer id.

Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy Response

Which browser capabilities this page and its frames may use.

An empty allowlist disables the feature outright. Replaces Feature-Policy, which used different syntax and no longer ships.

Permissions-Policy: geolocation=(), camera=(), microphone=()
X-Frame-Options Response Legacy

Blocks framing of this page. Two usable values: DENY, SAMEORIGIN.

Superseded by CSP frame-ancestors, which takes an origin list and wins when both are present in a modern browser. ALLOW-FROM was never implemented widely and is gone.

X-Frame-Options: DENY
Cross-Origin-Opener-Policy Response

Severs the window.opener link between your document and cross-origin ones.

Half of the cross-origin isolation pair, and worth setting on its own — it closes a family of tab-napping and cross-window probing attacks.

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy Response

Requires every subresource to opt in to being embedded here.

The other half. COOP plus COEP gives you crossOriginIsolated, which SharedArrayBuffer and high-resolution timers require. It will also break every third-party embed that does not send CORP.

Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Resource-Policy Response

Declares who is allowed to embed this resource at all.

Set cross-origin on public CDN assets or you break every isolated site embedding them. Set same-origin on anything private and you get a cheap defence against speculative-execution style leaks.

Cross-Origin-Resource-Policy: same-site
Reporting-Endpoints Response

Names the URLs that CSP, COOP, deprecation and crash reports get posted to.

The structured-field replacement for Report-To. Referenced by name from the report-to directive inside a CSP.

Reporting-Endpoints: csp="https://example.com/reports/csp"
Clear-Site-Data Response

Tells the browser to wipe cookies, storage or cache for this origin.

The values are quoted strings — unquoted is a silent no-op. Send it on logout. HTTPS only, and cache clearing support varies.

Clear-Site-Data: "cookies", "storage"
X-XSS-Protection Response Dead

Toggled a legacy browser XSS auditor.

The auditors were removed years ago and were themselves exploitable. If a compliance scanner insists, send 0. Otherwise delete it and write a real CSP.

X-XSS-Protection: 0
Sec-Fetch-Site Request

Relationship between the requesting page and the target: same-origin, same-site, cross-site or none.

Browser-set and unforgeable from JavaScript, which makes the Sec-Fetch family a cheap, cookie-free CSRF check that costs one string comparison.

Sec-Fetch-Site: same-origin
Sec-Fetch-Mode Request

How the request was made: navigate, cors, no-cors, same-origin or websocket.

A state-changing POST arriving with mode navigate and site cross-site is a form submission from somebody else's page.

Sec-Fetch-Mode: cors
Sec-Fetch-Dest Request

What the response will be used for: document, script, image, style, empty, and so on.

Reject a request whose Dest is script but whose URL is your JSON API and you have closed a whole category of cross-site data leak.

Sec-Fetch-Dest: empty
Sec-Fetch-User Request

Present only when a navigation came from a real user gesture.

Only sent on navigations, and only ever with that one value.

Sec-Fetch-User: ?1

Auth, cookies and sessions

Two things in this group catch people out. First, Authorization changes how your response is cached: a shared cache must not store it unless you explicitly opt in with public or s-maxage. That protection is real, and so is the footgun — adding public out of habit to an authenticated endpoint is how one customer's data ends up served to another from a CDN.

Second, Set-Cookie is the only header that legitimately appears many times in one response, and it must never be folded into a comma-separated list. Plenty of HTTP client libraries have shipped that bug. If you are writing one, keep the raw multi-value list; if you are using one, check before you trust headers.get.

Authentication, cookie and session headers
Header What it does Example
Authorization Request

Credentials for the target resource.

Its presence makes the response uncacheable by shared caches unless you explicitly say public or s-maxage. It is also the header the CORS wildcard refuses to cover.

Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…
WWW-Authenticate Response

The challenge that must accompany a 401: which scheme, which realm, and usually why the last attempt failed.

A 401 without this header is malformed. It is also the difference between a client that knows to refresh its token and one that retries the same dead credential forever.

WWW-Authenticate: Bearer realm="api", error="invalid_token", error_description="expired"
Proxy-Authorization Request

Credentials for the proxy in front of you, not for the origin.

Hop-by-hop: the proxy consumes it and must not forward it upstream.

Proxy-Authorization: Basic dXNlcjpwYXNz
Proxy-Authenticate Response

The 407 equivalent of WWW-Authenticate.

Proxy-Authenticate: Basic realm="corp-proxy"
Authentication-Info Response

Extra data at the end of a successful authenticated exchange — a next nonce, mutual-auth proof.

Digest and mutual schemes only. You will never write it by hand for a bearer-token API.

Authentication-Info: nextnonce="a1b2c3d4"
Cookie Request

Every cookie whose domain, path and flags match, concatenated, with no indication of who set which.

Cookies ignore ports and largely ignore scheme, so localhost:3000 and localhost:8080 share one jar. That is a debugging trap, not a feature.

Cookie: sid=8f3c1d…; theme=dark
Set-Cookie Response

Sets one cookie, with its lifetime and its security flags.

The one header that legitimately repeats — never join them with commas. SameSite=Lax by default; SameSite=None is only valid with Secure. The __Host- prefix forces Secure, Path=/ and no Domain, and is the strongest cookie you can ship.

Set-Cookie: sid=8f3c1d…; Max-Age=3600; Path=/; Secure; HttpOnly; SameSite=Lax

Rate limiting and retries

A 429 with no headers is a wall in the dark. The client learns it was refused and nothing about when to come back, so it retries immediately, gets refused again, and turns your rate limiter into a load generator. One Retry-After fixes that, and it is the single highest-value header in this section.

Beyond that there are two competing conventions. The X-RateLimit- triplet is the de-facto original, specified nowhere, with no agreement on whether the reset value is a Unix timestamp or a countdown. The IETF work — RateLimit and RateLimit-Policy — fixes that with structured fields and explicit parameters, but it is still a draft and the shape has changed between revisions. If you run a public API, emit both, document the unit in plain words, and remember to list them in Access-Control-Expose-Headers or your browser clients will never see them.

Rate limiting and retry headers
Header What it does Example
RateLimit Response Draft

Current quota state as a structured field: which policy, how many requests remain, seconds until reset.

From the IETF ratelimit-headers work. The field shape changed between drafts, so check the revision your library implements before you promise anything to clients.

RateLimit: "default";r=42;t=28
RateLimit-Policy Response Draft

The policy itself — quota and window length — so a client can pace itself instead of probing for the wall.

Send this alongside RateLimit and a well-written client never needs to hit a 429 at all.

RateLimit-Policy: "default";q=100;w=60
RateLimit-Limit Response Draft

Earlier-draft field: the quota for the current window.

Superseded by RateLimit-Policy, but this triplet is still what most shipping implementations emit.

RateLimit-Limit: 100
RateLimit-Remaining Response Draft

Earlier-draft field: requests left in the current window.

RateLimit-Remaining: 42
RateLimit-Reset Response Draft

Earlier-draft field: seconds until the window resets.

Delta-seconds here — unlike the X- version, which is where most of the confusion comes from.

RateLimit-Reset: 28
X-RateLimit-Limit Response De facto

The de-facto original. Quota for the window.

X-RateLimit-Limit: 5000
X-RateLimit-Remaining Response De facto

Requests left in the window.

Expose it through CORS or your browser clients cannot read it and will keep guessing.

X-RateLimit-Remaining: 4987
X-RateLimit-Reset Response De facto

When the window resets — and nobody agrees on the unit.

GitHub sends a Unix timestamp in seconds. Plenty of others send seconds remaining. A client that guesses wrong either hammers you immediately or sleeps for fifty-six years.

X-RateLimit-Reset: 1786527600
Retry-After Response

How long to wait before trying again — delta-seconds or an HTTP-date.

Valid on 429, on 503, and on a 3xx. Sending it on a 503 during a bad deploy turns a retry storm into an orderly queue, which is the cheapest availability win in this table.

Retry-After: 30

Proxies, forwarding, connection

The moment anything sits in front of your application — a load balancer, a CDN, an ingress controller — the connection your code sees is not the connection the user made. The client IP belongs to the proxy, the scheme is plain HTTP because TLS was terminated a hop ago, and the Host may have been rewritten. The X-Forwarded- family exists to carry the original facts across that gap, and Forwarded is the standardised version nobody quite finished adopting.

Every one of these is written by whoever is upstream, which includes the caller. That is the whole problem, and it is covered below.

Proxy, forwarding and connection headers
Header What it does Example
X-Forwarded-For Request De facto

The chain of client IPs, appended to by each proxy: original client first, nearest proxy last.

Anyone can send this header. Count in from the right through exactly as many hops as you actually operate, and never read element zero blindly.

X-Forwarded-For: 203.0.113.7, 198.51.100.17, 10.0.0.4
X-Forwarded-Proto Request De facto

The scheme the client originally used, before your load balancer terminated TLS.

Without it your app believes every request is plain HTTP and starts emitting http:// redirects that loop forever behind the balancer.

X-Forwarded-Proto: https
X-Forwarded-Host Request De facto

The Host the client originally asked for.

Build absolute URLs from this without an allowlist and you have host-header injection — including password-reset links that point at somebody else's domain.

X-Forwarded-Host: api.example.com
X-Forwarded-Port Request De facto

The port on the original client connection.

X-Forwarded-Port: 443
Forwarded Request

The standardised version of all of the above, in one field.

RFC 7239. Handles IPv6 and obfuscated identifiers properly, which X-Forwarded-For never did. Support is patchy, so most stacks emit both and trust neither by default.

Forwarded: for="[2001:db8::7]:9382";proto=https;host=api.example.com;by=203.0.113.43
X-Real-IP Request De facto

A single client IP. Nginx's convention.

Whatever the closest proxy decided the client was. Exactly as forgeable as X-Forwarded-For if that proxy does not overwrite it on every request.

X-Real-IP: 203.0.113.7
Via Request + response

Each proxy and cache that handled the message.

The standard hop record, and a good way to discover an intermediary nobody told you about.

Via: 1.1 varnish, 1.1 vegur
Host Request

Which virtual host on this connection the request is for. Mandatory in HTTP/1.1.

HTTP/2 and HTTP/3 use the :authority pseudo-header instead. Either way it is caller-controlled input — validate it against known hosts before it reaches routing or URL generation.

Host: api.example.com
Connection Request + response

Per-hop connection control, plus the list of other fields that are hop-by-hop.

Forbidden in HTTP/2 and HTTP/3, where a connection-specific field is a protocol error rather than a nuisance.

Connection: keep-alive
Keep-Alive Request + response

Idle timeout and request cap for a persistent HTTP/1.1 connection.

Set your client idle timeout below the server's or you will race it and see phantom connection resets under load.

Keep-Alive: timeout=5, max=1000
Upgrade Request + response

Ask to switch protocols on this connection — in practice, WebSocket.

Needs Connection: Upgrade beside it. Over HTTP/2 the mechanism is extended CONNECT instead, so a 101 never appears there.

Upgrade: websocket
Expect Request

One defined value: ask the server to approve the headers before you send a large body.

Saves uploading a gigabyte that a 413 was always going to reject. Some proxies mishandle it, which is why curl gives you an option to switch it off.

Expect: 100-continue
Early-Data Request

Marks a request that arrived in TLS 1.3 0-RTT data and is therefore replayable.

If the request is not idempotent, answer 425 Too Early and let the client resend on a fully established connection.

Early-Data: 1
Alt-Svc Response

Advertises the same service on another protocol or endpoint — how browsers discover HTTP/3.

Cached for ma seconds. It is the reason your first page load was HTTP/2 and the second was QUIC.

Alt-Svc: h3=":443"; ma=86400

▸ Classic mistake #4

Trusting X-Forwarded-For without a trusted-proxy list

X-Forwarded-For is a list that each proxy appends to. Nothing stops a client from sending one that is already populated, so what arrives at your app is the caller's fiction plus whatever your own infrastructure added. Reading the leftmost entry — the advice in most blog posts — means reading a value the attacker chose. That is how per-IP rate limits get bypassed, how IP allowlists get defeated, and how audit logs end up full of addresses that never existed.

The correct read is: start at the rightmost entry, which your nearest proxy appended and the client could not influence, and walk left past exactly as many addresses as you operate proxies. The first address beyond your own hops is the real client. Every serious framework has a setting for this — trusted proxies in Laravel and Symfony, ProxyFix in Werkzeug, trust proxy in Express, real_ip in nginx — and all of them need the hop count or CIDR list to be correct. Configure it per environment, because the number of hops in staging is rarely the number in production.

While you are there: have the edge overwrite X-Real-IP rather than pass it through, and strip any inbound X-Forwarded-Host unless you validate it — a forged Host is how password-reset links end up pointing at somebody else's domain.

Observability and odds and ends

The last group is the one that pays for itself during an incident. X-Request-ID generated at the edge, echoed on the response and stamped into every log line turns "a customer says checkout failed" into one grep. traceparent does the same across service boundaries, provided every hop propagates it untouched — and the hop that drops it is always the one nobody instrumented.

Server-Timing is the underused one: your backend phases appear directly in the browser's performance panel and in Resource Timing, which means real user monitoring without a vendor. It is invisible cross-origin unless you also send Timing-Allow-Origin, which is exactly the sort of detail that makes people conclude the header does not work.

Observability and general headers
Header What it does Example
Date Response

When the response was generated at the origin. Caches do all their freshness arithmetic against it.

A server with a badly skewed clock breaks Age and Expires maths in every cache along the path, and the symptom looks nothing like a clock problem.

Date: Tue, 11 Aug 2026 22:03:11 GMT
Server Response

The origin software.

Free reconnaissance for anyone scanning you. Strip the version number at minimum — no client has ever needed it.

Server: nginx
User-Agent Request

Client software string, historically a stack of compatibility lies.

Frozen and reduced in Chromium. Do not branch behaviour on it — use feature detection, or client hints if you genuinely need the device.

User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36
Referer Request

The page that linked here. The spelling is a 1996 typo baked permanently into the standard.

Trimmed to the origin cross-site by default. Never use it for authorisation, and never put secrets in URLs that other sites can link to.

Referer: https://example.com/blog/cdn-vs-cache/
Server-Timing Response

Server-side timings surfaced to the browser and to the Resource Timing API.

Cross-origin it is invisible unless you also send Timing-Allow-Origin. Real backend telemetry inside the browser's own waterfall, for the cost of one header.

Server-Timing: db;dur=53.2, cache;desc="hit";dur=0.4
traceparent Request

W3C Trace Context: trace id, parent span id and sampling flag for distributed tracing.

Propagate it verbatim through every hop, including queue messages. Dropping it in one service is how a single trace ends up as two unrelated ones.

traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
tracestate Request

Vendor-specific tracing state travelling beside traceparent.

tracestate: rojo=00f067aa0ba902b7,congo=t61rcWkgMzE
X-Request-ID Request + response De facto

A correlation id you can grep for across every service and every log line.

Generate it at the edge when the client did not send one, echo it on the response, and put it in every error body. Not standard, universally understood.

X-Request-ID: 01JCX9F0Z6QG3Y6M5R8P2N4TVA
X-Robots-Tag Response De facto

Indexing rules for crawlers, for responses that cannot carry a meta tag.

The only way to keep a PDF, a JSON endpoint, or an entire staging environment out of an index.

X-Robots-Tag: noindex, nofollow
Priority Request + response

Request urgency, and whether the response should be delivered incrementally.

RFC 9218. Urgency runs 0 to 7, defaults to 3, and lower is more urgent. Servers may honour it or ignore it entirely.

Priority: u=1, i

The baseline I ship every time

  1. On every HTML response: Strict-Transport-Security, X-Content-Type-Options: nosniff, Referrer-Policy: strict-origin-when-cross-origin, and a CSP with frame-ancestors. Four lines, no behaviour change.
  2. On every fingerprinted asset: Cache-Control: public, max-age=31536000, immutable. On the HTML that references them: no-cache, so a deploy is visible immediately.
  3. On anything content-negotiated: a Vary listing every request header the body depends on. Compression, language, and any echoed Origin.
  4. On every API response: an X-Request-ID that also appears in your logs and in error bodies, plus Access-Control-Expose-Headers so browser clients can actually read it.
  5. On every 429 and 503: a Retry-After. It is the difference between a queue and a stampede.
  6. Behind every proxy: a configured trusted-proxy list, and X-Forwarded-Proto honoured so you stop generating http:// redirects behind TLS termination.

Questions people ask

What is the difference between Cache-Control no-cache and no-store?

no-cache lets caches store the response but forbids reusing it without revalidating with the origin first, so you normally get a cheap 304 with no body. no-store forbids writing it to storage at all and is the only directive that really means do not cache. Use no-cache on HTML you want fresh but cheap, and no-store on responses carrying credentials or personal data.

What does the Vary header do?

Vary lists the request headers a cache must include in its cache key because they changed which representation you returned. If you compress based on Accept-Encoding, translate based on Accept-Language, or echo an Origin into Access-Control-Allow-Origin, those headers belong in Vary. Leave one out and a shared cache will serve the wrong variant to the next visitor, which looks exactly like a random bug and is really a missing header.

Which headers are needed for a CORS preflight request?

The browser sends OPTIONS with Origin and Access-Control-Request-Method, plus Access-Control-Request-Headers when the real request carries non-safelisted headers. Your server answers with Access-Control-Allow-Origin, Access-Control-Allow-Methods, Access-Control-Allow-Headers, and optionally Access-Control-Max-Age and Access-Control-Allow-Credentials. Two traps: the wildcard in Allow-Headers does not cover Authorization, and a wildcard origin is illegal once credentials are allowed.

Is X-Frame-Options still needed if I have CSP frame-ancestors?

Not for any browser released in the last decade. frame-ancestors supersedes X-Frame-Options, accepts a list of origins rather than one keyword, and wins when both headers are present. Keeping X-Frame-Options costs you nothing and covers very old clients, but if the two ever disagree, the CSP is the one that takes effect.

How do I get the real client IP behind a load balancer?

Read X-Forwarded-For or the RFC 7239 Forwarded header, but only after you decide which hops you trust. The header is a list that anyone can pre-seed, so parse it from the rightmost entry inward, skipping exactly as many addresses as you operate proxies, and take the first address beyond them. Frameworks call this the trusted proxy list, and configuring it is what turns a spoofable header into a usable one.

What is the difference between the RateLimit and X-RateLimit headers?

X-RateLimit-Limit, -Remaining and -Reset are the de-facto convention popularised by GitHub and copied everywhere, with no specification and no agreement on whether Reset is a Unix timestamp or seconds remaining. RateLimit and RateLimit-Policy come from the IETF ratelimit-headers work and use structured fields with explicit remaining, reset and quota parameters. Emit both if you have public clients, and always document the unit of your reset value.

Related

Keep reading