Identifying callers
The cache policy answers one question: two requests arrive, may they share an answer? Nothing
about a URL or a GraphQL document says whose data it returns, so only context the request carried
can tell two callers apart. The cache policy, in gpilot.toml, is where you say which context
that is. It works for plain HTTP and GraphQL alike, in three parts that build on each other:
- Providers verify a credential and make its claims readable.
- Expressions derive a value from those claims, or from headers, cookies and query parameters.
- Policy fields decide what that value does: partition private entries (
private), partition entries by a group or a preference ([[cache.policy.vary]]andpublic), or skip the cache (bypass).
Where each of these sits among the other ways to partition is on who shares an entry.
Authentication providers
Section titled “Authentication providers”[auth.providers.idp]type = "jwt"allowed_algorithms = ["RS256"]jwks_url = "https://idp.example.com/.well-known/jwks.json"allowed_issuers = ["https://idp.example.com/"]allowed_audiences = ["my-api"]A provider verifies a credential on the incoming request and makes its claims addressable as
auth:<provider>.<claim>. The block name (idp above) is what an expression refers to. It runs
on every request, cache hits included. Set allowed_issuers and allowed_audiences: a claim
partitions the cache only as well as the provider checks whose token it is, and a deployment warns
about a provider that leaves either out. See authentication for the
provider types and what happens to a request whose token does not verify.
Expressions
Section titled “Expressions”The private, public and vary value fields are policy
expressions over four sources. They combine with || (the first
value that resolves), && and + (concatenate; in these three fields every part is required, and
a value combining two or more sources keeps each one’s boundary, see combining sources in a cache
key), comparisons, parentheses, functions such as lower() and split(), and
string literals.
[cache.policy]private = 'auth:idp.sub || cookie:session_id'A value is only as trustworthy as its source
Section titled “A value is only as trustworthy as its source”| Source | Reads | Who decides the value |
|---|---|---|
auth:<provider>. | A claim from a verified credential, nested paths allowed (auth:idp.), and a quoted step for a URL-named claim (auth:idp.) | GraphPilot. Signature, expiry and algorithm were checked before any expression ran, so the caller cannot choose what a claim says. |
header:<name> | A request header | The caller |
cookie:<name> | A cookie | The caller |
query:<name> | A URL query parameter | The caller |
A caller-controlled source is right for a preference: header:accept-language partitions by
something the caller is entitled to choose, and a wrong choice harms nobody.
[cache.policy]private = 'auth:idp.sub'
[[cache.policy.vary]]name = "x-org"value = 'auth:idp.org_id'[cache.policy]private = 'header:x-user-id'
[[cache.policy.vary]]name = "x-org"value = 'header:x-org-id'In the broken version, sending x-user-id: <a colleague's id> is enough to be served that
colleague’s cached response, and x-org-id works the same way across organizations.
An empty value is a value
Section titled “An empty value is a value”An expression that resolves to nothing (its source is missing) leaves the caller unplaced: a
private response is not stored, and a named scope refuses to store. One that resolves to the
empty string (a blank header, ?x=, a cookie sent as name=) has resolved: that caller shares
the empty value’s entry with every other such caller. Derive what you partition on from claims
your identity provider never issues empty.
private
Section titled “private”The value a private response’s entry is partitioned by. A response is private when your origin
says Cache-Control: private or a selected field is scoped PRIVATE.
- If it resolves, the response is stored under that value. Two requests resolving to the same value share one entry; two resolving to different values never meet.
- If it does not resolve (not configured, or its source is missing on this request), the
response is not cached:
CACHE_SKIPPED_PRIVATE_WITHOUT_DISCRIMINATOR. It is never stored once and served to the next caller. - It must identify one caller, not their group.
auth:idp.subandcookie:session_iddo. An organization, a customer id, a role or a plan name do not: every member of the group then shares one entry, and nothing can validate this.
private also files a signed-in caller’s response that your origin did not mark public or
private, where a group-wide expression becomes a disclosure; see authenticated
requests.
For data that really is the same for a whole organization, do not widen private. Use a vary
rule, and for GraphQL a named scope on the types it is true of.
Vary rules
Section titled “Vary rules”A [[cache.policy.vary]] rule evaluates an expression and writes the result onto the request as a
header:
| Field | Type | Description |
|---|---|---|
name | String | The header the derived value is written to |
value | Policy expression | Computes the value |
pass_ | "never", "anonymized" or "plain" | Whether your origin receives the value, and in what form. Default never. |
scope_only | Boolean | Whether the rule is left to your schema’s named scopes: a response your schema decided is partitioned on it only when its scope names it. Default false. Accepted only with pass_to_origin = "never". |
[[cache.policy.vary]]name = "x-org"value = 'auth:idp.org_id'By default, a rule partitions every response, whether your schema or your origin’s
Cache-Control decided it, and whether or not a scope names the rule. Two callers whose rule
resolves to different values never share an entry: that is the safe direction, because not keying
on a value that shaped the response would serve one caller’s answer to another. The price is hit
rate. Each rule multiplies the entries a request needs, including requests whose answer does not
depend on it, so a role rule configured for one type also splits PUBLIC data, and data scoped to
an organization, by role. Configure only the rules that matter.
scope_only = true leaves a rule to your named scopes. A named scope lists the rules that
shape it in @vary. A rule marked scope_only partitions a response your schema decided only when
that response’s scope names it. This is what lets a named scope share one entry across its whole
audience, and PUBLIC data be shared by everyone, even where a finer rule such as x-role is
configured for a different type. PUBLIC and PRIVATE name no rules: their meaning is built in,
and a @vary written on either is ignored.
[[cache.policy.vary]]name = "x-role"value = 'auth:idp.role'scope_only = truescope_only is accepted only with pass_to_origin = "never", the default. A value your origin
receives can change a response no scope names, and keying on it is then the only thing that keeps
that response from reaching another caller, so any other combination refuses the deployment, with
a message naming the entry.
A response whose lifetime your origin’s Cache-Control decided keys on every configured rule,
scope_only or not, and whether or not anything names it. Nothing above the vary rules themselves
can tell which of them shaped such a response, so every one keeps partitioning it.
A rule that resolves to nothing writes no header. What happens to the response then depends on whether the request carried a credential:
- Without a credential, the response is stored as the variant where the header is absent. Only callers without a credential read that variant, so an anonymous caller never gets a signed-in caller’s answer from it.
- With a credential (an
Authorizationheader, a session cookie, or a header an auth provider reads), the response is not stored:CACHE_SKIPPED_PRIVATE_WITHOUT_DISCRIMINATOR. A rule that resolves to the empty string counts as unresolved here. Otherwise a signed-in caller whose token lacks the claim would be stored in the variant every anonymous caller reads. A rule markedscope_onlyis not checked this way on a response your schema decided: there, only a scope that names it partitions on it.
A named scope listing the rule refuses in both cases. A rule whose
name is not a valid header name is skipped.
A claim that is a number or a Boolean resolves to its JSON text (42, 1.5, true), so an
organization id your identity provider issues as a number partitions like a string one. null,
an object and an array resolve to nothing. Values are partitioned under an unambiguous encoding,
so two different values never share a variant. pass_to_origin still sends your origin the value
as it was derived, and nothing for a value that a header cannot carry, such as one with a control
character.
A value may combine claims, for an audience your origin also needs to tell apart:
[[cache.policy.vary]]name = "x-audience"value = 'auth:idp.org_id + ":" + auth:idp.role'pass_to_origin = "plain"For org org_7 and role admin this derives 5:org_7:5:admin: each claim is written as its
length in UTF-8 bytes, a colon, and the value, and the literal ":" stays as written. So no
separator has to be chosen with care: org a:b with role c and org a with role b:c derive
different values. A token missing either claim derives nothing, + or && alike: the caller
then shares the variant without the header, or, where a named scope lists the rule, gets no entry
at all. See combining sources in a cache
key.
Do not give a rule a gp- name. That prefix is reserved for headers GraphPilot sets itself and is
removed from every request before it leaves for your origin, so pass_to_origin cannot work for
it.
What your origin receives
Section titled “What your origin receives”pass_ | What the origin receives |
|---|---|
never (default) | Nothing. The header is removed before the request is forwarded. |
anonymized | v1= followed by an unpadded base64url HMAC-SHA256 digest, under a per-service key, of the rule’s header name and value. |
plain | The derived value, verbatim. For a value that combines two or more sources with + or &&, read it left to right: each part is either a literal from your expression, or a decimal length in UTF-8 bytes, :, and exactly that many bytes of the source’s value (5:org_ for auth:idp.). |
anonymized lets your origin tell two contexts apart without learning what distinguishes them.
The digest is keyed, because a bare hash of a low-entropy value can be reversed by trying every
candidate. The key is per service, never sent to you or your origin, and not your signing
key. The same value under two rules produces two digests. Parse the v1= prefix rather than assuming the whole value
is the digest.
Configuring a rule claims its header name: a value a client sends under that name is removed on the way in, even when the rule resolves to nothing, so a value under that name reaching your origin was always derived by GraphPilot.
What Insights records
Section titled “What Insights records”Every request whose entry a rule partitioned records the rule’s value in its telemetry, shown in
Insights as the request’s Segment and under Segmentation on its
Cache tab. pass_to_origin and telemetry.vary_values
decide the form of that value:
pass_ | Recorded with vary_values = "pseudonymized" (default) | Recorded with vary_values = "clear" |
|---|---|---|
never (default) | A keyed pseudonym, hmac-sha256: and 16 hex digits. | The derived value, in clear. |
anonymized | The same v1= digest your origin receives. | The same v1= digest your origin receives. |
plain | A keyed pseudonym, hmac-sha256: and 16 hex digits. | The derived value, in clear. |
The pseudonym is made with the same per-service key as the v1= digest, for your telemetry
alone: it is not what a plain origin receives, and it cannot be turned back into the value.
Equal values give equal pseudonyms, so requests of one segment still group together, and the
Segment filter matches a pseudonym copied from a request. The Cache tab marks each value as
pseudonymized, anonymized or clear.
A value in clear never equals its pseudonym. Requests recorded before a change of vary_values
keep the form they were stored in, so requests of one segment can appear as two groups across that
deployment. Requests recorded before runtime 1.6.0 store the value of a never or
plain rule in clear, the way "clear" does now.
The private and public expressions have no pass_to_origin. Their values are always
recorded as a keyed digest, never in clear.
public
Section titled “public”An expression that partitions the entries of shared, non-private responses, for example per
language. Like private, its value partitions the entry internally and is not part of the cache
key itself.
[cache.policy]# An origin that answers in German or in English.public = "starts_with(lower(header:accept-language), 'de')"The predicate collapses every browser’s Accept-Language string into true or false, so each
response has two entries rather than one per distinct string; see
cardinality. Derive the value the way your origin
picks the language: a caller placed in the false entry must be one your origin answers in
English. The value is not sent to your origin; for more than two languages, or an origin that
should be told the language, use the vary rule in caching per language.
The private and public values are never sent to your origin and are not stored in the cached
response. A client cannot supply them either: GraphPilot removes every request header in the
reserved gp- namespace a client sends (see headers).
Your origin’s Vary header
Section titled “Your origin’s Vary header”Your origin can partition without any configuration. Every request header it names in its
Vary response header becomes part of that entry’s key, and the edge keeps one variant per
distinct value:
Cache-Control: max-age=60, publicVary: accept-languageThis adds to the vary rules: beside an x-org rule it keeps one entry per organization and
language. The headers your vary rules derive do not need to be named: a response whose lifetime
your origin’s Cache-Control decided keys on every rule already.
Naming the credential itself, Vary: authorization, is what lets a response to a signed-in
caller stay shared; see keeping the shared entry with
Vary.
The one value that does not work is the literal Vary: *, which is not a wildcard over header
names: RFC 9111 specifies that it
always fails to match a later request. GraphPilot does not store such a response
(CACHE_SKIPPED_VARY_WILDCARD). If you mean “do not reuse this”, send Cache-Control: no-store;
if the response varies by something, name the headers.
bypass
Section titled “bypass”When this expression matches a request, the request goes to your origin without the cache being
consulted, and its response is not written back. Reported as PASS with
CACHE_PASS_BYPASS_POLICY.
[cache.policy]bypass = "header:x-preview-token"bypass decides rather than derives a value, so it is evaluated as a truth; see values versus
truths. A request carrying none of the
context the expression names never bypasses. Unset by default: every bypass is an origin request
the cache would otherwise have absorbed.
A bypass on a header alone, as above, is open to anyone who learns the header’s name. To give only your own staff a live view, require a verified claim as well:
[cache.policy]bypass = "auth:idp.role == 'admin' && header:x-preview"An admin who sends x-preview: 1 reaches your origin and is shown what it answers right now, for
example a price that is not yet published, and that answer is not written back. Everyone else is
served from the cache, header or not: for a caller with another role the comparison is false, and
for a caller with no role it has no answer, and an expression with no answer does not bypass. To
keep a debugging header such as server-timing from everyone but the same admins, use a
conditional response
rule.
Keeping identifiers out of Insights with pseudonymize
Section titled “Keeping identifiers out of Insights with pseudonymize”pseudonymize(s) replaces a value with a keyed
pseudonym, 43 characters made with a secret key of your service, before the cache, your origin or
your telemetry see it. Equal inputs give equal pseudonyms within the service, so a partition built
on one still groups the same callers. It is accepted only in the values that partition the cache:
cache.policy.public, cache.policy.private and a vary rule’s value. Anywhere else, such as
bypass, a response rule’s when, telemetry.record or a client profile, the deployment is
refused with a message that says pseudonymize() is only allowed in cache.policy.public and names
the keys that call it.
What it changes depends on where the value goes. For a verified token with org_id = "acme", a
sub and an email:
A vary rule’s value | Insights, vary_values = "pseudonymized" (default) | Insights, vary_values = "clear" | A plain origin receives |
|---|---|---|---|
auth:idp. | hmac-sha256: and 16 hex digits | acme | acme |
auth:idp. | hmac-sha256: and 16 hex digits | The email address, in clear | The email address |
pseudonymize(auth:idp. | hmac-sha256: and 16 hex digits | The 43-character pseudonym | The pseudonym |
auth:idp. | hmac-sha256: and 16 hex digits | 4:acme:43: and the pseudonym | 4:acme:43: and the pseudonym |
- Under the default, Insights shows a digest either way. There
pseudonymizechanges what the cache partitions on and what aplainorigin receives, not what Insights shows. - Under
"clear", it decides which part of a value is readable. That setting applies to every rule of the service, so it is the reason to wrap the personal part of a value: an organization id or a language can be read in clear, an email address or a user id should not be. - In
privateandpublicit changes nothing Insights shows, because those values are always recorded as a keyed digest. It still keeps the clear value out of what the entry is partitioned on. - The last row is length-prefixed because it combines two sources, see vary
rules; a single source wrapped in
pseudonymizeis the pseudonym alone.
The table assumes pass_to_origin = "plain" or "never". Under "anonymized", the origin and
Insights both get the v1= digest of the derived value, pseudonym or not. How the three settings
combine is on combining the options.
Example: an origin that personalizes without learning who is asking
Section titled “Example: an origin that personalizes without learning who is asking”A recommendations origin, run as a service of its own, answers per shopper and should never receive an email address or a token:
[[cache.policy.vary]]name = "x-shopper"value = "pseudonymize(auth:idp.email)"pass_to_origin = "plain"
[origin]headers = { hide = ["authorization"] }Each signed-in shopper gets an entry of their own (a visitor without a token derives no value and
shares the entry kept for requests without one), and the origin receives x-shopper with the pseudonym,
the same one on every request from that shopper, which it can keep per shopper without being able
to turn it back into the address. The token is dropped just before the request is forwarded,
after it was verified and the cache key was built, so the origin sees no credential at all; have it
check request signing to know a request came through GraphPilot.
- A service of its own, because a rule the origin receives partitions every response and
cannot be marked
scope_only. auth:idp.subis the stabler input where your identity provider issues one: an email address can change, and with it the pseudonym and every entry keyed on it.pass_to_origin = "anonymized"onauth:idp.emailwould hand the origin an equally opaquev1=value.pseudonymizeis the one to reach for when only part of a value should be hidden, as in the last row of the table.
A per-user scope that keeps the organization readable in Insights is on scopes in the
schema, and
worked through for five users in partitioning your
cache. A private
expression built on a pseudonym is on authenticated
requests,
and what a pseudonym changes about purging on revoking
access.
Examples
Section titled “Examples”A JWT for the caller and a claim for a group
Section titled “A JWT for the caller and a claim for a group”[auth.providers.my_auth_provider]type = "jwt"allowed_algorithms = ["HS256"]secret = "secret"allowed_issuers = ["https://idp.example.com/"]allowed_audiences = ["my-api"]
[cache.policy]private = 'auth:my_auth_provider.sub'
[[cache.policy.vary]]name = "x-org"value = 'auth:my_auth_provider.org'pass_to_origin = "anonymized"Private responses are stored once per user (sub), and every response, plain HTTP included,
once per organization as well. The rule cannot be marked scope_only, because your origin
receives its value. sub survives a token refresh, where keying on the token itself would start a
new entry per token.
One entry per language
Section titled “One entry per language”[[cache.policy.vary]]name = "x-locale"value = "lower(split(split(split(header:accept-language, ',', 0), ';', 0), '-', 0)) || 'en'"pass_to_origin = "plain"A browser sends Accept-Language: de-DE,de;q=0.9,en;q=0.8. The rule takes the first language
range (de-DE), drops a ;q= weight if it has one, keeps the language in front of the region
(de) and lowercases it, so callers sending de-DE, de-AT or DE all derive de and share one
entry. A rule on the raw header would keep one entry per distinct browser string instead; see
cardinality.
Your origin receives x-locale: de and has to render from that header, not from
Accept-Language. || 'en' gives a caller without the header the en entry; without it, a
request with a credential and no Accept-Language is not stored
(CACHE_SKIPPED_PRIVATE_WITHOUT_DISCRIMINATOR). The full walk-through, with the requests to send
and what Insights shows, is the recipe caching per language.
The raw Authorization header
Section titled “The raw Authorization header”[cache.policy]private = 'header:authorization'This is safe only if an [auth.providers.*] block reads Authorization, so a token that does not
check out is refused before it can key anything. Without one, an expired or revoked token whose
exact value was cached keeps being served that entry until it expires. Prefer reading a claim, as
above.
Two criteria for one caller
Section titled “Two criteria for one caller”[cache.policy]private = 'auth:my_auth_provider.sub && cookie:session_id'&& resolves to nothing if either part is missing, so an entry is served only to a caller who
matches both, and a request without the token or the cookie is not cached. The key carries each
part with its length, so no sub and cookie pair can derive another pair’s key.
Partitioning answers “who”, not “until when”
Section titled “Partitioning answers “who”, not “until when””An entry keyed to one caller is still served for its whole lifetime without your origin being asked, so a revoked session or a withdrawn permission is not reflected until it expires or you purge it. See revocation.