Skip to content

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:

  1. Providers verify a credential and make its claims readable.
  2. Expressions derive a value from those claims, or from headers, cookies and query parameters.
  3. Policy fields decide what that value does: partition private entries (private), partition entries by a group or a preference ([[cache.policy.vary]] and public), or skip the cache (bypass).

Where each of these sits among the other ways to partition is on who shares an entry.

gpilot.toml
[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.

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.

gpilot.toml
[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”
SourceReadsWho decides the value
auth:<provider>.<claim>A claim from a verified credential, nested paths allowed (auth:idp.org.id), and a quoted step for a URL-named claim (auth:idp.'https://acme.example/org_id')GraphPilot. Signature, expiry and algorithm were checked before any expression ran, so the caller cannot choose what a claim says.
header:<name>A request headerThe caller
cookie:<name>A cookieThe caller
query:<name>A URL query parameterThe 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.

gpilot.toml (correct)
[cache.policy]
private = 'auth:idp.sub'
[[cache.policy.vary]]
name = "x-org"
value = 'auth:idp.org_id'
gpilot.toml (broken)
[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 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.

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.sub and cookie:session_id do. 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.

A [[cache.policy.vary]] rule evaluates an expression and writes the result onto the request as a header:

FieldTypeDescription
nameStringThe header the derived value is written to
valuePolicy expressionComputes the value
pass_to_origin"never", "anonymized" or "plain"Whether your origin receives the value, and in what form. Default never.
scope_onlyBooleanWhether 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".
gpilot.toml
[[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.

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

scope_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 Authorization header, 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 marked scope_only is 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:

gpilot.toml
[[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.

pass_to_originWhat the origin receives
never (default)Nothing. The header is removed before the request is forwarded.
anonymizedv1= followed by an unpadded base64url HMAC-SHA256 digest, under a per-service key, of the rule’s header name and value.
plainThe 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_7:5:admin for auth:idp.org_id + ":" + auth:idp.role).

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.

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_to_originRecorded 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.
anonymizedThe same v1= digest your origin receives.The same v1= digest your origin receives.
plainA 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.

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.

gpilot.toml
[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 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, public
Vary: accept-language

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

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.

gpilot.toml
[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:

gpilot.toml
[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 valueInsights, vary_values = "pseudonymized" (default)Insights, vary_values = "clear"A plain origin receives
auth:idp.org_idhmac-sha256: and 16 hex digitsacmeacme
auth:idp.emailhmac-sha256: and 16 hex digitsThe email address, in clearThe email address
pseudonymize(auth:idp.email)hmac-sha256: and 16 hex digitsThe 43-character pseudonymThe pseudonym
auth:idp.org_id + ':' + pseudonymize(auth:idp.sub)hmac-sha256: and 16 hex digits4:acme:43: and the pseudonym4:acme:43: and the pseudonym
  • Under the default, Insights shows a digest either way. There pseudonymize changes what the cache partitions on and what a plain origin 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 private and public it 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 pseudonymize is 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:

gpilot.toml
[[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.sub is 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" on auth:idp.email would hand the origin an equally opaque v1= value. pseudonymize is 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.

A JWT for the caller and a claim for a group

Section titled “A JWT for the caller and a claim for a group”
gpilot.toml
[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.

gpilot.toml
[[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.

gpilot.toml
[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.

gpilot.toml
[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.