1. Docs
  2. Authorization Caching

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.

Grant roots, per identity. Each permission the identity holds, and the nodes it was granted at. Small by construction: a person has a handful of role assignments, not a handful of thousands.The hierarchy, once per process. Node to parent, nothing else, shared by every identity your server handles. The shape of your tree does not depend on who is asking, so it is not held per user.

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:

A role assignment created, rescheduled, or removed.A role's permissions changed, or the role deactivated or deleted.An identity deactivated, or its Environment membership revoked.An organization membership deactivated. A grant at or beneath an organization counts only while the holder's membership there is active, so removing a member drops every grant they held inside it, after the same window.A node moved. This one is easy to overlook: moving a node changes what every inherited grant reaches, even though no grant itself changed. The SDK revalidates the hierarchy on the same cadence for exactly this reason.

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.

A token cannot be invalidated. It is a snapshot signed at login. Revoking access cannot reach it: you would wait out the token's own lifetime, which is not yours to set per check. A cache's window is.A token cannot carry your hierarchy. It travels on every request, so its size is the binding constraint. Without the tree, a token can never answer at this node, only whether the permission is held somewhere.

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:

Read every page of 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.Read every page of GET /api/v1/nodes/parents once for the process, and keep the node-to-parent map.Check 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.Revalidate with 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.To answer a check, climb from the node in question through its parents and return true if any of them appears in that permission's grant roots.For a list, ask 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.

Environment
API version
v1.0
On this page Was this page helpful?

Tell us how we can improve this guide.