`descope/authzcache`: The Sidecar That Makes Fine-Grained Authorization Feel Local
A deep dive into the caching layer that keeps Descope’s auth graph fast, indexed, and usable even when the remote service is not.
- `authzcache` treats authorization as a graph problem, so the cache has to understand relations, reverse paths, and partial hits instead of just storing key-value answers.
- The service can answer some permission checks locally, fetch only the missing pieces remotely, and merge everything back in order, which is where most of its speed comes from.
- Its indexing model is the real invention: eviction, reverse lookups, and freshness polling all have to stay consistent or the cache becomes misleading instead of fast.
- This repo is not an authorization engine. It is a local acceleration layer that keeps a central Descope source of truth while moving evaluation close to the app.
Why a cache has to understand authorization graphs
A plain cache works when the answer is a single key. Authorization is rarely that polite. In FGA and ReBAC systems, a permission check can depend on a chain of relations, so every request that misses locally risks becoming a network round trip to a central service.
`descope/authzcache` exists to collapse that distance. Descope’s own framing is clear: keep the central model in Descope, but evaluate permissions locally at high speed, often in sub-millisecond time, where the application runs.
| Approach | What it understands | Latency goal | Tradeoff |
|---|---|---|---|
| Redis-style cache | Keys and values | Fast reads | Blind to graph shape |
| Full FGA engine | Relations, traversal, model rules | Correct evaluation | More work per check |
| authzcache | Relations plus reverse paths plus freshness | Fast local checks | More indexing complexity |
That middle position is the point. `authzcache` is not trying to replace the authorization engine. It is trying to make graph-shaped authorization cheap enough to live beside the app.
The cache is not just storage. It is an authorization brain
The cache service acts as a local cache layer between your application and Descope's authorization services. It automatically syncs with remote authorization data and provides fast, local access to relationship and permission information needed for FGA checks.
The orchestrator in `internal/services/authz.go` is the traffic cop. It first asks the local project cache, then only sends the missing pieces to the remote SDK, then stitches the response back into the original order. That hybrid merge is the real trick.
How the project stores a graph without becoming a graph database
The center of gravity lives in `projectauthzcache.go`. Instead of one bucket of answers, it keeps direct relations, reverse lookups, and LRU state in sync so it can answer both sides of the graph: who can reach this resource, and what can this user reach.
| Structure | What it helps answer | Why it matters |
|---|---|---|
| directRelationCache | Is this specific relation already known? | Fast point lookups |
| directResourcesIndex | Which resources match this user or relation? | Reverse traversal without scanning |
| directTargetsIndex | Which targets point back here? | Keeps indirect queries cheap |
| LRU eviction queue | What should be removed first? | Controls memory without breaking indexes |
That last row is what makes the repo feel engineered instead of improvised. When a relation falls out of the cache, the code also cleans the manual indexes, so stale graph paths do not linger after the data is gone.
if cachedHit {
return cachedResult
}
missing := filterUncached(checks)
remote := mgmtSDK.FGA().Check(ctx, missing)
mergeResultsInOriginalOrder(cached, remote)
updateProjectCache(remote)
Why partial hits matter more than full hits
This service is built for mixed traffic. A real request rarely lands entirely in one bucket. Some relations are hot, some are cold, and a few are freshly changed. The interesting thing is that `authzcache` does not force the whole request through the slow path just because one piece is missing.
That matters because authorization checks are often batched. If seven out of ten checks are already local, the other three are the only ones that need a remote call. The cache keeps the fast parts fast instead of punishing the whole batch.
The operational lesson is simple. Good caches do not just maximize hit rate. They minimize the cost of the miss that remains.
Freshness is optional until it is not
The repo’s most opinionated choice is how it handles staleness. A purge cooldown window lets the cache keep serving stale data when remote refresh fails, instead of wiping everything and forcing every request back to the source.
That is a deliberate availability tradeoff. In a networked auth system, an empty cache is not neutral. It is a latency bomb. The cooldown keeps the service useful while the remote side is temporarily unhealthy.
| Mode | What happens on refresh failure | User experience |
|---|---|---|
| Strict purge | Cache is emptied immediately | Every check slows down |
| Cooldown serving | Stale data continues briefly | System stays fast and usable |
| Remote-only fallback | All checks leave the node | Correct, but expensive |
The control plane is deliberately boring
The controller layer does the unglamorous work: it bridges gRPC and REST through `grpc-gateway`, translates protobuf types into internal SDK types, and keeps the core logic transport-agnostic. That is exactly what you want in infrastructure code.
The metrics path is equally sober. The collector tracks things like candidate and filtered counts, then the reporter pushes snapshots back to Descope’s management plane. This is not just a cache you run. It is a service you operate.
What this repo is really competing with
| System | Role | Where it differs |
|---|---|---|
| authzcache | Local acceleration layer | Caches graph-shaped auth near the app |
| OpenFGA | Authorization engine | Owns model evaluation and policy logic |
| Plain cache | Generic storage accelerator | Does not understand relations or reverse paths |
That distinction is the whole story. `authzcache` is not trying to become a general-purpose policy engine. It is a local extension of a managed auth platform, built to make the expensive part feel close.
That means you keep your central model in Descope, but evaluate permissions at high speed — often in sub-millisecond time — right where your application runs.
Why this architecture matters
The broader lesson reaches beyond Descope. If authorization is graph-shaped and latency-sensitive, the cache cannot be dumb. It has to know about relations, eviction, reverse traversal, and stale-serving rules, or it will either be slow or misleading.
`descope/authzcache` makes that tradeoff explicit. It keeps the source of truth centralized, moves evaluation close to the app, and accepts a little extra indexing work in exchange for much better runtime behavior.





