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
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| Cloudflare custom 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 | 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 | 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 | 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.
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.
Related how-tos
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
-
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 -
RFC 7519 defines the
subclaim 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 -
Varnish’s users guide prints the built-in
vcl_hashwith seven lines inside its braces: the URL, then theHostheader or, failing that, the server’s own IP address. Everything on this page is one morehash_datacall in that block, and the guide does not mention callers, tenants, orAuthorizationat all. The default is a shared cache for a world with one kind of visitor. ↩︎ Back to text