Authorization Caching
How an authorization check is answered inside your own process, what the SDK holds to do it, and the window between changing someone's access and that change taking effect.
How a check is answered
A guarded route does not call Canopy. The SDK fetches two things and keeps them: where an identity holds each permission, and the shape of your hierarchy, and every check after that is a walk up the tree in your own memory. One call per identity per 60 seconds replaces one call per request, and your request path no longer waits on ours.
What is cached, and where
Two caches, split by what they depend on. Both live inside your process; Canopy stores nothing extra on your behalf.
A grant already means this node and everything beneath it, so the grant roots are never expanded into their descendants. That is what keeps the per-identity cache small even for an administrator granted at the top of a large tree, and it is why a check is a walk rather than a lookup: the SDK climbs from the node in question and asks whether any ancestor is one of the identity's grant roots. Measured, the hierarchy costs about 90 bytes per node, one id string and one map entry: 4.6 MB for a 50,000-node tenant, against the 50–100 MB an idle Node process already holds. It is shared across every identity your server handles, so it does not grow with your user count. The same two caches answer two more questions without a call: accessibleNodes expands an identity's grant roots for one permission through their descendants, for a list query, and permissionsAt lists what the identity holds at one node. See Filter Lists by Access.
The 60-second window
With the SDK's default window, an authorization change takes effect within 60 seconds: sometimes sooner, never later.
Which means the thing worth saying plainly: after you revoke someone's access, they can still act for up to 60 seconds.
The window covers every change that can alter an answer:
Why permissions are not in the token
A reasonable question, especially coming from a provider that does it: why not put the permissions in the access token and skip the fetch entirely? Two things prevent it.
Canopy does emit an Environment-wide permissions claim, the permissions the identity holds anywhere in the Environment, for OAuth clients that request the permissions scope, which is useful if you integrate with a stock OIDC library and only need the loose question answered. The SDK deliberately does not read it: two sources for one answer, with two different staleness windows, is worse than one.
Without the SDK
None of this is SDK-only. Both endpoints are ordinary HTTP, and the algorithm is short enough to write yourself:
GET /api/v1/identities/{id}/grants?include=windows for the identity, one row per permission, node and assignment with its effective_from and effective_to, and keep it for up to 60 seconds. Apply the window yourself: a row is a grant only while it is in effect, and it lets you say whether a failed check is a grant that has ended or one that has not begun.GET /api/v1/nodes/parents once for the process, and keep the node-to-parent map.model_version on every page of both. Version 1 is the walk described here; refuse any other rather than walking rows whose meaning has changed.If-None-Match on the first page, carrying the ETag from that page. A 304 means the whole hierarchy is unchanged and your copy is still good; anything else means read every page again.GET /api/v1/identities/{id}/accessible-nodes?permission=: the minimal grant roots and the nodes beneath them up to max_nodes, or env_wide when no filter is needed. Or compute the same thing from your copy: every node whose climb reaches a root.That is the entire mechanism. The SDK adds the caching, the revalidation timer and the failure handling, but nothing about it is privileged. To test your own version, replay the cases Canopy publishes at /conformance/authorization.json: each pins the grant roots and the tree an Environment answers, and whether each check is allowed, the same cases Canopy's own suite and the SDK's replay.
Serverless and edge
The design assumes a long-running process. On a serverless function or at the edge, every cold start begins with an empty cache and refetches both the grant roots and the tree. It still works, and it is still far better than a call per request, but the saving is smaller and it surfaces as slower cold starts rather than as steady-state latency.
Tuning
The window is the SDK's ttlMs option, 60 seconds by default. It is yours to shorten, and there is one trap worth knowing before you do.
A window shorter than the gap between a user's requests saves nothing. Every request finds the cache expired and refetches, which is a call per request, the very thing the cache exists to avoid. Human-paced traffic has multi-second gaps between clicks, so a window of a few seconds can cost full price for no benefit.
If you need a change to land faster than the window, don't shorten the window: clear the cache when you know access changed. Call the SDK's invalidate(identityId) when your backend changes someone's access, or from a webhook for an assignment change; invalidate() with no argument clears everything, for a change whose reach you can't name (a role's permissions edited, a role deactivated, a node moved). It reaches only the process that calls it, so across several server instances the window stays the guarantee. Setting ttlMs to 0 reads fresh on every check, at the cost of a call per request.
Tell us how we can improve this guide.