Skip to content

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.

  • 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.
gpilot.toml
[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_algorithms names RS256, the algorithm Auth0 signs API access tokens with by default. It is required.
  • allowed_issuers is required for a provider with a jwks_url. Copy the issuer with its trailing slash: Auth0 writes iss that way, and the comparison is exact.
  • allowed_audiences keeps tokens Auth0 issued for your other APIs out. Auth0 can issue aud as an array; a token is accepted when any of its audiences is on the list, and a token without aud is 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.

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

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

schema.graphql
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.

Terminal window
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:'
done

With Ada and Grace both in org_acme with the role admin:

gp-cache: MISS
gp-cache: HIT

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

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.

  • Organization data selected beside private data is private. A query that selects me and reports together 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 audience of your API is issued for Auth0’s own APIs or as an opaque token, and fails allowed_audiences or does not parse as a JWT. Request tokens with audience=https://shop-api.example.com.