How-to › Make calls that survive failure

How to build cache keys that include the caller and Vary headers#

Stop a shared cache serving one tenant's response to another: key on the caller, the negotiated representation, and the query parameters that matter.

Audience
Platform team
Level
advanced
Topic
Cache API responses
Verified

A CDN in front of your API serves tenant A’s invoice list to tenant B. The origin sent Vary: Authorization and Cache-Control: private, and the edge keyed on the URL alone. Nothing in the origin’s logs shows it, because the origin was never asked. The first sign is a support ticket from tenant B, who can read invoices that are not theirs.

What you get

You will end up with a key builder that scopes each route to its caller and negotiated representation, and a test that warms the cache as one tenant and reads as another. This is for you if a shared cache sits between your API and its callers.

Short answer

Split routes into two classes. A public route keys on method, path, the query parameters that change the body, and the normalized Accept, Accept-Encoding, and Accept-Language values. A per-caller route adds the tenant claim your auth layer verified, never the bearer token. Prove it by warming the cache as one tenant and reading as another, through every cache in the path.

You will need

Node 22 or later, and a shared cache with more than one caller behind it: a CDN, Varnish, Redis, or a Map in a long-lived process. RFC 9111 forbids a shared cache from reusing a response to a request that carried Authorization, unless the response says public, s-maxage, or must-revalidate. Every cache on this page is a shared one.

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
Cloudflare custom cache keysThe cache is Cloudflare’s and the split is by query string, header, or cookieHeader and cookie keys sit on the Enterprise list, and a wide key shards the cacheThe cache you need to fix is not the edge
Hand-built tenant-scoped keysAn in-process or Redis cache that your own code writes toA policy per route to maintain, and a key that is wrong the day a route changesThe cache is a proxy that never sees your code
Varnish hash_dataVarnish fronts the API and you own its VCLOne more hash_data call per attribute, and a hit ratio that falls with each oneThe cache is a hosted CDN with no VCL to edit
Vary headerEvery cache in the path honors Vary, and the origin knows what it varied onA header with many values, such as Authorization, gives one entry per token and a cache that never hitsA CDN in the path does not key on Vary unless configured

Vary is the one option that travels with the response, so a cache that honors it is correct without configuration and a cache that ignores it is wrong without warning. Varnish and Cloudflare key on request attributes you name up front, which no forgotten header can undo, and each attribute you add divides the hit ratio. The hand-built key is the same idea inside your own process, with the policy in code where a test can reach it.

Split routes into two key classes

Keying on identity makes a cache safe and useless in the same move: a rate table keyed by tenant is fetched once per tenant instead of once. So the decision is per route, not per cache. A public route carries no caller in its key. A per-caller route does. Both carry the negotiated representation, because an entry built for br and French is the wrong bytes for a client that asked for gzip and English.

export function cacheKey(req, {
  policy = 'public',
  query = [],
  vary = ['accept', 'accept-encoding', 'accept-language'],
  caller = {},
} = {}) {
  const url = new URL(req.url, 'http://placeholder.invalid')
  const params = [...url.searchParams].filter(([name]) => query.includes(name)).sort()
  const path = url.pathname + (params.length ? `?${new URLSearchParams(params)}` : '')
  const parts = [req.method.toUpperCase(), path]
  for (const name of vary) parts.push(`${name}=${normalizeHeader(name, req.headers[name])}`)
  if (policy === 'per-caller') parts.push(`caller=${callerId(req, caller)}`)
  return parts.join('|')
}

The query list is an allowlist. Only the parameters that change the body go into the key, so a tracing parameter or a cache-busting timestamp does not create a fresh entry per request, and a status filter does. The header values are normalized before they are joined. RFC 9111 section 4.1 says two requests match when their headers differ only in whitespace, in order where order is not significant, or in case where the field is case-insensitive.1 A cache that compares bytes instead gives gzip, br and br,gzip two entries. Weights go too, except q=0, which RFC 9110 defines as not acceptable, so gzip;q=0, br drops gzip from the key.

Put the caller in the key, not the token

Three ways to name the caller, and one of them is a pitfall.

export function callerId(req, { mode = 'claim', secret } = {}) {
  if (mode === 'claim') return req.auth?.tenant ?? 'anonymous'
  const token = String(req.headers.authorization ?? '').replace(/^Bearer\s+/i, '')
  if (mode === 'hmac') return createHmac('sha256', secret).update(token).digest('hex').slice(0, 16)
  // The pitfall, kept only so the demo can show what it does.
  return token
}

claim reads the tenant your auth layer wrote onto the request after it verified the token. That is the right default: the cache is scoped to the thing access control is scoped to, and a token rotation changes nothing.2 hmac is for a cache that sits before verification and sees only the token. An HMAC of the token under a key of your own is stable for the token’s lifetime and useless to anyone who reads the cache. The raw token is the pitfall: it lands in the store, in every log line that prints the key, and in the next engineer’s debugging session. And it ties the entry to the token rather than the tenant, so every rotation is a cold cache with the old entries still holding that tenant’s data until they expire.

Check it worked

Warm the cache as tenant acme, read the same URL as tenant birch, and repeat across two encodings and two languages, through three cache configurations in one process. vary-ignored keys on the URL alone and reads nothing else. vary-honored keys on the URL and applies the response’s Vary header the way RFC 9111 says. It stores neither a private response nor the answer to an Authorization request that lacks public, s-maxage, or must-revalidate. explicit takes the key from the route policy.

node demo.mjs
warm as acme, then read the same URL as birch
mode           encoding  language  birch receives
vary-ignored   gzip      en        acme's body, LEAK
vary-ignored   gzip      fr        acme's body, LEAK
vary-ignored   br        en        acme's body, LEAK
vary-ignored   br        fr        acme's body, LEAK
vary-honored   gzip      en        own body
vary-honored   gzip      fr        own body
vary-honored   br        en        own body
vary-honored   br        fr        own body
explicit       gzip      en        own body
explicit       gzip      fr        own body
explicit       br        en        own body
explicit       br        fr        own body

same representation, different encoding, under vary-ignored
asked for gzip and en, served br and fr

what goes into the key for GET /me/invoices?status=open&trace=9f
claim  GET|/me/invoices?status=open|accept=application/json|accept-encoding=gzip|accept-language=en|caller=acme
hmac   GET|/me/invoices?status=open|accept=application/json|accept-encoding=gzip|accept-language=en|caller=6933abd267f281c9
raw    GET|/me/invoices?status=open|accept=application/json|accept-encoding=gzip|accept-language=en|caller=tok_a1

acme rotates its token from tok_a1 to tok_a2
claim  caller before=acme after=acme warm cache
raw    caller before=tok_a1 after=tok_a2 cold cache, and the old key still holds acme data

The first block is the leak, four times over, and it is not subtle: vary-ignored hands birch acme’s body on every combination. The origin did everything right. It sent Vary: Authorization, Accept-Encoding, Accept-Language and Cache-Control: private, and the cache read neither. vary-honored read both and stored nothing for a private route. The second block is the quieter cousin: the same cache serves br French bytes to a client that asked for gzip English. The key lines show trace=9f dropped and status=open kept, and the raw mode carrying tok_a1 where the other two carry a tenant name.

The test runs the same matrix with assertions. It also asserts the failure, because a test that cannot see the leak cannot tell you when a cache in the path starts producing it.

node --test key.test.mjs
1..8
# tests 8
# suites 0
# pass 8
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 116.956746

Run the same four requests through your CDN as well, with tenant B’s response read for the edge’s cache status header and for the body’s owner. The two caches disagree about Vary, and the one in front is the one that answers your callers.

Where the edge and the process disagree

The vary-ignored mode is not a straw man. Cloudflare’s cache key documentation says the default key is the full URL plus a handful of named headers. Its Vary documentation says a Vary header from the origin takes effect only for header names you configure in a Cache Rule. Each configured header gets an action: normalize, passthrough, or bypass. A header the origin lists and you did not configure falls to the rule’s default. Vary for images, which picks an image format from Accept, is a separate feature with its own rule.

The caches inside your own process are the other way round. undici’s cache store selects a stored response by comparing the request against the response’s Vary header. http-cache-semantics, which sits under the cache option of got, evaluates Vary and authenticated responses from a shared cache’s point of view by default. So the same response is safe in the process and leaked at the edge, which is why the check has to run through both.

When it goes wrong

Tenant B sees tenant A’s data, and the origin’s own cache is innocent. The edge keyed on the URL. Add the caller to the edge’s key through its own mechanism, a Cache Rule or hash_data,3 and stop relying on Vary: Authorization to do it.

Every request is a miss after a deploy that changed nothing but the auth library. The key holds the raw token, so when the library changed its token format, every caller rotated at once. Key on the verified claim, and the entries survive.

Two clients that ask for the same thing get two entries, and the hit ratio halves. One sends Accept-Encoding: gzip, br and the other br, gzip, and the key compares bytes. Normalize the header before it goes into the key, which Cloudflare also offers as the normalize action and warns against skipping with passthrough.

When not to do this

Do not key public data on the caller. A currency table keyed by tenant is fetched once per tenant, which for a thousand tenants is a thousand origin requests for one answer. Give the route a public policy and let every caller share the entry.

Do not rely on Vary: Authorization to protect a route at a CDN. The header is correct by RFC 9110, and a cache that keys on the URL alone will not read it. Key the edge yourself, or mark the route uncacheable there.

Do not trust a claim the request brought with it. req.auth.tenant is written by your auth layer after it verified the token, and a tenant id read from an unverified header is a key any caller can choose. If the cache runs before verification, use the HMAC of the token and accept the cold cache on rotation.

Do not put a cookie or User-Agent into a shared key without a plan for cardinality. Each is close to one value per client, and the cache degrades into a per-client store with the memory budget of a shared one.

Last verified

Verified 2026-09-24 against Node 22.22.2. Both output blocks are what the preceding command printed. The three cache modes run in one process against a stand-in origin: vary-ignored stands in for a shared cache that does not key on Vary, because no CDN was in the path.

Footnotes

  1. The matching rule has a companion the specification states without ceremony: a header absent from one request matches only a request where it is also absent. Cloudflare’s Vary documentation offers the other philosophy as an option called passthrough, under which two requests that differ by one space are two requests, and recommends against it for most deployments. ↩︎ Back to text

  2. RFC 7519 defines the sub claim as an identifier that is locally unique in the context of the issuer, or globally unique, and stops there. It does not define a tenant claim, because a tenant is a fact about your product and not about tokens. The claim the key reads is one you named and one your auth layer has to verify. The specification’s authors left that part to the reader, and the reader is you. ↩︎ Back to text

  3. Varnish’s users guide prints the built-in vcl_hash with seven lines inside its braces: the URL, then the Host header or, failing that, the server’s own IP address. Everything on this page is one more hash_data call in that block, and the guide does not mention callers, tenants, or Authorization at all. The default is a shared cache for a world with one kind of visitor. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Get the Voxgig dispatch

Short notes on building SDKs, CLIs, REPLs, and MCPs for API-first teams, plus the occasional Fireside episode pick.

By signing up you agree to our Terms and Conditions.