Authorization at Scale
Authorization at scale means millions of actors, resources, and relationships, deep hierarchies such as folders within folders, and decisions needed on nearly every request within a few milliseconds. Those decisions are also made in many places at once: the public API, service backends, background workers, webhooks, and admin tools all need the same answer, at an acceptable cost.
Where Time Goes
Rendering diagram…
Diagram source
flowchart LR
check["check(user, edit, doc)"] --> cache{Decision cache}
cache -->|hit| result[Decision]
cache -->|miss| engine[Policy engine]
engine --> walk[Walk relationships: doc → folder → folder → team → user]
walk --> store[(Relationship store)]
engine --> result
The cost of a decision grows with the depth of the relationships it has to follow. A document nested ten folders deep, shared through nested groups, can turn one check into dozens of lookups.
Techniques
| Technique | Effect | Trade-off |
|---|---|---|
| Decision caching | Repeated checks return in microseconds. | Stale decisions until invalidation; see Revocation & Consistency. |
| Flattened group membership | Nested groups resolved ahead of time into direct memberships. | Recomputation when group structure changes. |
| Purpose-built engines | Relationship engines such as SpiceDB or OpenFGA, designed for graph-shaped checks. | Another datastore to run and keep consistent. |
| Batch checks | Many decisions in one call, for pages that show many resources. | Requires engine and API support. |
| Engine placement | Engine as a sidecar or library next to the service, avoiding a network hop. | Policy and data must be distributed to every replica. |
Distributed Enforcement
Every entry point must enforce access, and each check it makes adds latency. Splitting the work by layer keeps checks cheap where they can be and precise where they must be.
Rendering diagram…
Diagram source
flowchart LR web[Web app] --> gw[API gateway] mobile[Mobile app] --> gw partners[API clients] --> gw gw -->|authenticate, coarse checks| svc1[Projects service] gw --> svc2[Billing service] queue[(Job queue)] --> worker[Workers] hooks[Inbound webhooks] --> svc2 admin[Admin tools] --> svc1 svc1 -->|fine-grained check| engine[Policy engine] svc2 -->|fine-grained check| engine worker -->|fine-grained check| engine admin -->|fine-grained check| engine
| Layer | Enforces | Does not replace |
|---|---|---|
| Gateway or edge | Authentication, token validity, scopes, rate limits | Resource-level decisions, which need product data. |
| Service | Resource-level authorization through the policy engine | Data-layer protection against bugs in the service. |
| Data layer | Organization isolation, for example row-level security | Business rules that depend on more than the tenant. |
Each entry point accepts its own kind of credential: session cookies, bearer tokens, API keys, OAuth tokens, or service certificates. Authentication normalizes all of them into one principal, so authorization code never inspects a credential and every service decides from the same structure:
{
"type": "user",
"id": "usr_…",
"organizationId": "org_…",
"scopes": ["projects:read"],
"authMethod": "api_key",
"actingFor": null,
"authenticatedAt": "2026-11-02T14:03:11Z"
}Design Points
- Measure the shape of the data. Hierarchy depth, group nesting, and the fan-out of shares predict performance better than raw counts.
- Bound the model. Limit nesting depth and the number of paths the engine may follow; unbounded models eventually produce unbounded latency.
- Load test with realistic data. Generate data with the depth and skew of production, including the largest customer, and test with cold and warm caches.
- Deny by default in the framework. Routes and resolvers without an explicit authorization declaration fail to register or return 403. Making a route public is a deliberate, reviewed choice.
- One enforcement library. Every service uses the same client for principals and checks, so behavior, caching, and logging are identical everywhere.
- Route inventory. A generated list of every endpoint with its authentication and authorization requirement, checked in CI, makes gaps visible in review.
- Workers and webhooks carry identity. Jobs record the principal that created them and re-check permissions when they run. Inbound webhooks verify signatures and map to a specific integration principal.
Watch For
- Hot resources. A resource shared with the whole organization is checked constantly; make sure its decisions are cached and its invalidation does not cascade.
- Per-request fan-out. A list page that checks each item individually multiplies latency by the page size. That is the problem described in Authorization-Aware Data Access.
- GraphQL and batch endpoints. One request can touch many resource types; authorization belongs on each field or resolver, not only on the top-level query.
- Internal tools. Admin dashboards are often the least protected and most powerful entry point.
- Inconsistent results. The same action allowed in the API but denied in the UI, or the reverse, signals duplicated rules. Move them into the policy engine.