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 in one table
Section titled “The rule in one table”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
Authorizationheader; - a non-empty value in a header an
[auth.providers.<name>]block is configured to read, such asx-api-key; - a
Cookieheader naming at least one cookie that is not oncache.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- | Request carries a credential | Result |
|---|---|---|
max-age=60, public | yes | Cached and shared |
s-maxage=60 | yes | Cached and shared |
max-age=60 + Vary: authorization | yes | Cached and shared, keyed by the credential |
max-age=60, private | yes | Partitioned, as always |
max-age=60 | no | Cached and shared |
max-age=60 | yes, and a private cache policy resolves | Partitioned |
max-age=60 | yes, and no private cache policy resolves | Not 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.
Why a bare max-age is not enough
Section titled “Why a bare max-age is not enough”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
Authorizationheader 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.
[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=authorizationheader 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:
[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 asauth:idp.subdoes. Only the value the partition is built on is the pseudonym rather than thesub. - Insights shows a keyed digest either way. A
privatevalue is always recorded as a keyed digest, undertelemetry.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 samesubgives 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.
When nothing can be cached
Section titled “When nothing can be cached”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: PASSgp-cache-reason: CACHE_SKIPPED_CREDENTIALED_REQUESTThe 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 configurecache.policy.privateso the entry can be partitioned, and ideally also mark the responseCache-Control: max-age=60, privateso 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.
Keeping the shared entry with Vary
Section titled “Keeping the shared entry with Vary”An origin whose response really does depend on who asked can name the credential in Vary:
Cache-Control: max-age=60Vary: authorizationThe 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
Authorizationheader and aCookie, thenVary: authorizationalone 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 inAuthorizationnext to an individual session inCookie: 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 readsx-api-key, a request carrying it needsVary: x-api-key(next to any other credential it carried). A provider configured withheader = "Cookie"declares the cookie its credential, so the wholeCookieheader counts andcache.cookies.allowno longer excuses any of it. -
Varymust name the credential itself. AVarynaming 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
Varyis 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.
Revocation
Section titled “Revocation”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.
Details worth knowing
Section titled “Details worth knowing”@cacheControl in your schema is exempt
Section titled “@cacheControl in your schema is exempt”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.
Cookie counts, even though the RFC only names Authorization
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.