Caching per Auth0 organization
Goal: your B2B app signs users in with Auth0 Organizations. A sales report is the same for every member of one organization with one role, so it should be cached once per organization and role, not once per user, and never served across organizations.
The mechanism is the same for any identity provider that issues JWTs with an organization claim; partitioning your cache explains it in general. This recipe fills in the Auth0 values.
The running example is shop-api behind the Auth0 tenant shop-example.eu.auth0.com. Its
API is registered in Auth0 with the identifier https://shop-api.example.com, which becomes the
aud of every access token for it. With Organizations, Auth0 puts the organization’s id in the
org_id claim. A post-login Action adds the member’s role as shop_role.
What you need
Section titled “What you need”- An Auth0 API whose access tokens carry
org_id, and a role claim if what members may see depends on their role. - A GraphQL schema and a deployed service. For a plain HTTP API, the same vary rules apply without the schema step; see vary rules.
1. Verify the token
Section titled “1. Verify the token”[auth.providers.auth0]type = "jwt"allowed_algorithms = ["RS256"]jwks_url = "https://shop-example.eu.auth0.com/.well-known/jwks.json"allowed_issuers = ["https://shop-example.eu.auth0.com/"]allowed_audiences = ["https://shop-api.example.com"]allowed_algorithmsnames RS256, the algorithm Auth0 signs API access tokens with by default. It is required.allowed_issuersis required for a provider with ajwks_url. Copy the issuer with its trailing slash: Auth0 writesissthat way, and the comparison is exact.allowed_audienceskeeps tokens Auth0 issued for your other APIs out. Auth0 can issueaudas an array; a token is accepted when any of its audiences is on the list, and a token withoutaudis refused.
The provider reads the bearer token from Authorization. A token that does not verify is answered
401 at the edge and never reaches your origin. See authentication.
2. Derive the organization and the role
Section titled “2. Derive the organization and the role”[cache.policy]private = 'auth:auth0.sub'
[[cache.policy.vary]]name = "x-gp-organization"value = 'auth:auth0.org_id'scope_only = true
[[cache.policy.vary]]name = "x-gp-role"value = 'auth:auth0.shop_role'scope_only = trueprivate keys data that belongs to one person on the Auth0 user id. The two vary rules derive
the organization and the role from the verified token; a client cannot choose either.
scope_only = true leaves each rule to the schema scopes that name it, so public data stays
shared by everyone.
The role is there because the organization alone is not a safe bucket. A cache hit never reaches your origin, so whatever your origin would check for the second caller is not checked. Keyed on the organization alone, a member with a viewer role would be served the report an admin of the same organization had filled the cache with.
3. Name the scope in the schema
Section titled “3. Name the scope in the schema”directive @vary(names: [String!]!) on ENUM_VALUE
enum CacheControlScope { PUBLIC PRIVATE ORGANIZATION @vary(names: ["x-gp-organization", "x-gp-role"])}
type Report @cacheControl(maxAge: 300, scope: ORGANIZATION) { range: ReportRange! revenue: Int! orders: Int!}
type Me @cacheControl(maxAge: 60, scope: PRIVATE) { id: ID! favorites: [Product!]!}A response that selects a Report is stored for five minutes, once per organization and role. A
response that selects Me is stored once per user. A product list with no named scope is stored
once for everyone.
Deploy the configuration first and the schema second, or both together. A @vary naming a rule
that gpilot.toml does not define refuses every request it applies to.
4. Send two members of one organization
Section titled “4. Send two members of one organization”for token in "$ADA_TOKEN" "$GRACE_TOKEN"; do curl -sS -D- -o /dev/null "https://$GRAPHPILOT_HOST/graphql" \ -H 'content-type: application/json' \ -H "authorization: Bearer $token" \ -d '{"query":"query Sales { reports(range: DAY) { revenue orders } }"}' \ | grep -i '^gp-cache:'doneWith Ada and Grace both in org_acme with the role admin:
gp-cache: MISSgp-cache: HITA token for a member of another organization, or for a viewer of org_acme, is a MISS of its
own. A token without org_id (a user signed in outside any organization) derives no value for
x-gp-organization, and the scope then refuses to store the response rather than share it:
CACHE_SKIPPED_PRIVATE_WITHOUT_DISCRIMINATOR.
5. Read it in Insights
Section titled “5. Read it in Insights”In Insights, the request’s Cache tab shows Segmentation Vary,
split by x-gp-organization and x-gp-role, each as a keyed pseudonym marked
pseudonymized. Ada’s and Grace’s requests show the same two pseudonyms, and Requests in
this bucket lists everyone who read that entry. The Vary-segmented preset finds every
request served from an entry of this kind, and Private responses the per-user ones.
Pitfalls
Section titled “Pitfalls”- Organization data selected beside private data is private. A query that selects
meandreportstogether is stored per user, the narrowest scope it touched. Split the query if the report should be shared. - Revoking a member’s access takes effect in the cache only when the entry goes. A removed member’s token stays valid until it expires, and the entries it could read stay cached until they expire or are purged. See revoking access.
- An Auth0 access token for the wrong API is refused. A token requested without the
audienceof your API is issued for Auth0’s own APIs or as an opaque token, and failsallowed_audiencesor does not parse as a JWT. Request tokens withaudience=https://shop-api.example.com.