# 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.

Source: https://voxgig.com/howto/cache-keys-that-include-the-caller

- Audience: platform-team
- Level: advanced
- Verified: 2026-09-24
- Published: 2026-09-24

## 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](https://www.rfc-editor.org/rfc/rfc9111.html#section-3.5) 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

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [Cloudflare custom cache keys](https://developers.cloudflare.com/cache/how-to/cache-keys/) | The cache is Cloudflare's and the split is by query string, header, or cookie | Header and cookie keys sit on the Enterprise list, and a wide key shards the cache | The cache you need to fix is not the edge |
| [Hand-built tenant-scoped keys](https://redis.io/docs/latest/develop/use/keyspace/) | An in-process or Redis cache that your own code writes to | A policy per route to maintain, and a key that is wrong the day a route changes | The cache is a proxy that never sees your code |
| [Varnish `hash_data`](https://www.varnish.org/docs/users-guide/vcl-hashing/) | Varnish fronts the API and you own its VCL | One more `hash_data` call per attribute, and a hit ratio that falls with each one | The cache is a hosted CDN with no VCL to edit |
| [Vary header](https://www.rfc-editor.org/rfc/rfc9111.html#section-4.1) | Every cache in the path honors Vary, and the origin knows what it varied on | A header with many values, such as Authorization, gives one entry per token and a cache that never hits | A 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.

```ts title="key.mjs"
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](https://www.rfc-editor.org/rfc/rfc9111.html#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](https://www.rfc-editor.org/rfc/rfc9110.html#section-12.4.2) 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.

```ts title="key.mjs"
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](https://www.rfc-editor.org/rfc/rfc2104.html) 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.

```bash
node demo.mjs
```

```text output
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.

```bash
node --test key.test.mjs
```

```text output
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](https://developers.cloudflare.com/cache/how-to/cache-keys/) says the default key is
the full URL plus a handful of named headers. Its
[Vary documentation](https://developers.cloudflare.com/cache/concepts/vary/) 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](https://developers.cloudflare.com/cache/advanced-configuration/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](https://undici.nodejs.org/#/docs/api/CacheStore.md) selects a stored response by comparing the request against the response's Vary header.
[http-cache-semantics](https://cdn.jsdelivr.net/npm/http-cache-semantics@4.2.0/README.md), 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](https://www.rfc-editor.org/rfc/rfc9110.html#section-12.5.5), 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.

## Related how-tos

- [Rate limit by API key and tenant, not client IP](/howto/key-rate-limits-by-api-key-and-tenant)

- [Diagnose a 401 or a 403 from a credential](/howto/diagnose-401-and-403-from-a-credential)

## 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.

[^1]: The matching rule has a companion
[the specification](https://www.rfc-editor.org/rfc/rfc9111.html#section-4.1) states without
ceremony: a header absent from one request matches only a request where it is also absent.
Cloudflare's
[Vary documentation](https://developers.cloudflare.com/cache/concepts/vary/) 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.

[^2]: [RFC 7519](https://www.rfc-editor.org/rfc/rfc7519.html#section-4.1.2) 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.

[^3]: Varnish's [users guide](https://www.varnish.org/docs/users-guide/vcl-hashing/) 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.