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

8 min read • View on GitHub • More from descope

A local sidecar-shaped mechanism sits beside a distant cloud fortress, with two internal chambers for direct relations and reverse lookup paths. Thin local threads answer most permission checks nearby, while a longer path reaches remote authorization only for missing pieces, explaining how the cache keeps graph-shaped auth fast without abandoning the source of truth.
The key idea is not memoization. It is local graph projection with a remote fallback.
Key Takeaways

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.

ApproachWhat it understandsLatency goalTradeoff
Redis-style cacheKeys and valuesFast readsBlind to graph shape
Full FGA engineRelations, traversal, model rulesCorrect evaluationMore work per check
authzcacheRelations plus reverse paths plus freshnessFast local checksMore 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 orchestrator does not just ask the cache for a hit or miss. It can answer part of the question locally, fetch only the missing relations, and merge the results back together.

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.

Descope Documentation, Project Documentation · FGA Cache | Descope Documentation

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

A close-up cross-section shows one relation card being removed from an LRU stack while linked reverse index boards delete the same relation from multiple maps at once. The image explains that eviction in this cache is not only memory management, but also consistency management across direct and reverse indexes.
Eviction is a consistency operation here, not just a memory-saving operation.

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.

StructureWhat it helps answerWhy it matters
directRelationCacheIs this specific relation already known?Fast point lookups
directResourcesIndexWhich resources match this user or relation?Reverse traversal without scanning
directTargetsIndexWhich targets point back here?Keeps indirect queries cheap
LRU eviction queueWhat 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.

ModeWhat happens on refresh failureUser experience
Strict purgeCache is emptied immediatelyEvery check slows down
Cooldown servingStale data continues brieflySystem stays fast and usable
Remote-only fallbackAll checks leave the nodeCorrect, 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

SystemRoleWhere it differs
authzcacheLocal acceleration layerCaches graph-shaped auth near the app
OpenFGAAuthorization engineOwns model evaluation and policy logic
Plain cacheGeneric storage acceleratorDoes 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.