Skip to content

Authenticated requests

A client usually attaches its credential to every request: an API client sets the Authorization header once, a browser sends its session cookie on every call. A request for public data and a request for someone’s inbox look identical apart from what they ask for. Whether a response was computed for one caller or is the same for everybody is a question only your origin can answer, and by default it does not. This page describes what GraphPilot does when the answer is missing.

The rule applies to plain HTTP and GraphQL alike, whenever your origin sent a Cache-Control header and that header is what made the response cacheable. A request counts as credentialed when it carries any of these:

  • a non-empty Authorization header;
  • a non-empty value in a header an [auth.providers.<name>] block is configured to read, such as x-api-key;
  • a Cookie header naming at least one cookie that is not on cache.cookies.allow.

The cookie check is per cookie, not per header. A pair with no readable name, such as the =secret in Cookie: GAESA=abc; =secret, counts as a credential on its own, because it cannot be checked against the allowlist. Separator noise does not: Cookie: GAESA=abc; is an ordinary allowlisted jar. A value that is nothing but separators (;;), or that GraphPilot cannot read as text, counts as a credential.

Your origin’s Cache-ControlRequest carries a credentialResult
max-age=60, publicyesCached and shared
s-maxage=60yesCached and shared
max-age=60 + Vary: authorizationyesCached and shared, keyed by the credential
max-age=60, privateyesPartitioned, as always
max-age=60noCached and shared
max-age=60yes, and a private cache policy resolvesPartitioned
max-age=60yes, and no private cache policy resolvesNot cached

The last two rows are where this rule changes the outcome, and only the last one loses you a cache entry. “Partitioned” means filed under your cache.policy.private value, and nothing more; read your expression before you rely on those rows.

RFC 9111 section 3.5 is unusually direct here, and it is one of the few places the specification does not leave the decision to the cache:

A shared cache MUST NOT use a cached response to a request with an Authorization header field to satisfy any subsequent request unless a cache directive that allows such responses to be stored is present in the response.

A shared cache needs a statement it could only have been given on purpose. max-age says how long the answer stays fresh, which is just as true of a response meant for one person. public and s-maxage mean nothing except to a cache that other people also read from. Many shared caches simply bypass the cache for a request carrying Authorization or Cookie; GraphPilot is deliberately gentler, because refusing outright would punish the ordinary client described above.

What happens instead of refusing: the partitioned entry

Section titled “What happens instead of refusing: the partitioned entry”

If a private cache policy is configured and resolves for the request, GraphPilot files the entry under that policy’s value, exactly where a response marked Cache-Control: private would have gone.

gpilot.toml
[cache.policy]
private = 'auth:idp.sub || cookie:session_id'

That one line turns the last row of the table into the second-to-last, and it also decides what happens to a response your origin does mark private. This shows up only as an edge code on the request, which the portal lists under Edge codes in the Insights Overview tab:

CACHE_STORED_PRIVATE header=authorization

header names the credential header: authorization, cookie, or the header an auth provider reads. The response carries gp-cache: MISS and no gp-cache-reason, because the entry was stored, and the next matching request from the same caller is a HIT. See cache statuses for how the two headers relate.

Read your private expression before you rely on this

Section titled “Read your private expression before you rely on this”

A partitioned entry also costs hit rate: it follows how often the same caller repeats a query, not how often anyone does. For genuinely public data behind a signed-in session that is a large difference, and the fix is one word, public.

Partitioning on a pseudonym instead of the user id

Section titled “Partitioning on a pseudonym instead of the user id”

A REST endpoint of the shop, GET /me/favorites, answers each signed-in shopper with their own list, and the origin sends a bare Cache-Control: max-age=60. The partition only has to tell shoppers apart, not say who they are, so it can be built on a pseudonym of the subject instead of the subject itself:

gpilot.toml
[cache.policy]
private = 'pseudonymize(auth:idp.sub)'
  • The cache key is unchanged, and so is the hit rate. The entry is still keyed on the method and path, GET:/me/favorites, and filed under the caller’s partition. One shopper always derives the same 43 characters, so each still has exactly one entry, and it survives a token refresh as auth:idp.sub does. Only the value the partition is built on is the pseudonym rather than the sub.
  • Insights shows a keyed digest either way. A private value is always recorded as a keyed digest, under telemetry.vary_values = "pseudonymized" and "clear" alike, and your origin never receives it.
  • The pseudonym is keyed. It is made with a secret key of your service that neither you nor your origin holds, so it cannot be turned back into the sub. The same sub gives a different pseudonym on another service.

To see per-shopper traffic grouped in Insights, or to let your origin tell shoppers apart without learning who they are, the value has to go into a vary rule instead, where vary_values and pass_to_origin apply; see an origin that personalizes without learning who is asking.

If no private policy resolves for the request, there is nothing to key a narrower entry on, and the only safe thing left is not to store. The response is delivered normally and reported as:

gp-cache: PASS
gp-cache-reason: CACHE_SKIPPED_CREDENTIALED_REQUEST

The log line names the request header that triggered it, so you can tell an Authorization case from a Cookie one or from an auth provider’s header, and for a Cookie it also names the first cookie that was not on cache.cookies.allow. A pair with no name is reported as <unreadable>, the same word the CACHE_SKIPPED_SET_COOKIE line uses for a Set-Cookie it could not read: there is no name to add to the allowlist, and the fix is to stop sending the malformed pair.

To get caching back, pick whichever is true of the response:

  • It is the same for every caller. Send Cache-Control: max-age=60, public. Best outcome: one shared entry, served to everybody.
  • It differs per caller. Either name the credential in Vary, as described below, or configure cache.policy.private so the entry can be partitioned, and ideally also mark the response Cache-Control: max-age=60, private so the intent is in the response rather than inferred from the request.
  • It should not be cached at all. Send Cache-Control: no-store, which is explicit and does not depend on this rule.

An origin whose response really does depend on who asked can name the credential in Vary:

Cache-Control: max-age=60
Vary: authorization

The entry is then keyed by that header’s exact value, so a request carrying a different credential can never match it, and GraphPilot stores the response normally. See your origin’s Vary header for how Vary becomes part of the cache key.

Three conditions:

  • Every credential the request carried has to be named. If your clients send an Authorization header and a Cookie, then Vary: authorization alone is not enough, and such a response falls back to the partitioned entry above. The shape this protects against is a customer-wide API key in Authorization next to an individual session in Cookie: keying on the API key would share one person’s answer with everyone at that customer while looking as though the credential had been accounted for.

  • A header an auth provider reads counts too. If an [auth.providers.<name>] block reads x-api-key, a request carrying it needs Vary: x-api-key (next to any other credential it carried). A provider configured with header = "Cookie" declares the cookie its credential, so the whole Cookie header counts and cache.cookies.allow no longer excuses any of it.

  • Vary must name the credential itself. A Vary naming something else does not count, even when it partitions the cache usefully. This includes a header a vary rule of yours derives:

    gpilot.toml
    [[cache.policy.vary]]
    name = "x-org"
    value = 'auth:idp.org'

    Naming it in Vary is not permission to share, because it partitions the caller’s group, not the caller. Such a response is filed under your private policy as well, so it ends up keyed by organization and by caller.

Every cached authenticated response, shared, partitioned or Vary-keyed, stays servable until it expires or is purged. Taking access away is yours to act on: see revoking access.

The rule reads your origin’s HTTP header, and only that. If a response is cacheable because of a @cacheControl annotation in your schema instead, it is stored the way the annotation says, credential or no credential.

An annotation is a statement you wrote on one field, which cannot get there by accident the way a framework’s default max-age can. Apollo Server reads it the same way: its plugin turns @cacheControl(maxAge: 60) into Cache-Control: max-age=60, public.

Section titled “Cookie counts, even though the RFC only names Authorization”

A session cookie is the same credential in a different envelope, and a rule ignoring cookies would miss most of the traffic it exists for. A header an auth provider reads is a credential for the same reason.

An empty Authorization value does not count. Authorization: with nothing after it identifies nobody, and some HTTP clients emit one by accident.

Cookie is judged differently: not by presence, but by whether it names anybody. A Cookie header whose every cookie is on cache.cookies.allow identifies nobody either: those are load balancer affinity and bot-management cookies, not a session. The exception is a provider that reads Cookie, described above.

must-revalidate is not accepted as permission

Section titled “must-revalidate is not accepted as permission”

RFC 9111 names three directives that permit sharing: public, s-maxage, and must-revalidate. GraphPilot honors the first two and deliberately not the third.

must-revalidate is on that list because a cache that obeys it asks the origin before every use past the lifetime. GraphPilot has no conditional revalidation: it honors the directive by dropping the stale window, and an expired entry is an ordinary miss. Accepting the directive as permission would mean accepting an allowance granted on a promise GraphPilot does not keep, so this is stricter than the specification, on purpose.

Send public or s-maxage to express what must-revalidate was standing in for.

It does not affect what your client receives

Section titled “It does not affect what your client receives”

This rule only decides whether and where GraphPilot stores a copy. The response your client gets is your origin’s response, headers included, on the stored path and the refused path alike.