Coarse-Grained vs Fine-Grained Authorization

The granularity spectrum every authorization decision sits on, from perimeter gateways down to per-relationship checks: what each level costs in latency, modeling effort and blast radius, how layered architectures combine coarse and fine checks, why naive per-item checks create an N+1 authorization problem, and a way to choose the right granularity per feature instead of by default.

One role, one quiet mistake

The support console at the cashback company was simple when Daniel built it. Anyone with the support-agent role could open any customer’s account, because support agents needed to help any customer who called. One role, one check, nothing to model. Two years later the company signs a contract with a large retail partner whose customers must only be handled by a dedicated team, and an auditor asks a direct question: can an agent outside that team open one of those accounts? The honest answer is yes. Nothing broke and nothing logged an error. The check simply never asked which account it was looking at.


The last two articles both assumed you already knew what kind of question you were asking. Policy as code assumed a flat rule over attributes was the right shape and covered how to write, test and ship it. Fine-grained authorization assumed a graph question was the right shape and covered how to model and query it. Neither asked the question that comes first, on every feature you build: how much does this decision need to know about the specific resource involved, for this specific caller, before it can be trusted?


The answer runs from “nothing at all” to “everything about a specific relationship between this exact user and this exact resource”, and where a decision falls on that range is what this article calls granularity. Miss in one direction and you overbuild: a relationship graph and a Check call for a decision that was always going to be “any logged-in employee, yes.” Miss in the other and you underbuild, which is what happened to Daniel’s console: a single role guarding something that was supposed to differ per customer. Both mistakes are common, and both are avoidable once granularity is something you choose on purpose instead of something you inherit.


A spectrum, not two boxes

“Coarse-grained” and “fine-grained” sound like two boxes, but real systems make authorization decisions at several layers, and each one answers a different kind of question at a different cost.


The Granularity Spectrum Four authorization layers, coarsest to finest: perimeter, module/role, resource, relationship — cost rises and the decision depends on more specific data as granularity increases. The Granularity Spectrum Where an authorization decision sits determines what it costs ← coarser finer → cost: latency, data modeling, operational surface low high Perimeter (network & gateway) examples: "is this IP allowed?" "is this API key valid?" Module / Role (RBAC, feature flags) examples: "is this user an admin?" "can support agents see the billing module?" Resource (ownership & attributes) example: "does this user own this invoice?" tools: XACML PDP, OPA, Cedar, Casbin Relationship (graph reachability) example: "was this document shared with this user through this group?" tool: Zanzibar / OpenFGA decision depends on: nothing about the specific resource a specific relationship between this exact subject and object Same request, different granularity — the shape of the question determines the layer, not the tool.
The granularity spectrum. Every authorization decision sits somewhere on this bar, and where it sits decides both what it can express and what it costs. Nothing on the fine end is more correct than the coarse end: a decision that genuinely doesn't depend on the specific resource is correctly modeled as coarse, not under-modeled.

The perimeter is network ACLs, an API gateway checking that a key or bearer token is valid, a WAF rule. Its question is whether this caller may talk to the system at all. It knows nothing about resources, roles or relationships, and it shouldn’t.


The module or role layer asks things like “is this user an admin” or “can support agents see the billing module”, and it’s where feature flags live too. This is classic RBAC: the decision depends on who the caller is, their role or group, but not on which specific resource they’re asking about. A Casbin matcher checking role == "admin" lives here, and so does a boolean claim in a JWT.


The resource layer asks “does this user own this invoice” or “is this document still a draft, and did this user create it”. Now the decision depends on attributes of one specific instance, compared with the caller. This is where the XACML PDP, OPA and Cedar earn their keep, matching subject, resource, action and environment against policy.


The relationship layer asks “was this document shared with this user, directly or through a group or a folder, at any depth”. The decision depends on a specific edge or path between this exact subject and this exact object, which is the whole reason Zanzibar and OpenFGA exist.


It’s tempting to read that list as “the perimeter uses a gateway, the module uses RBAC, the resource uses OPA, the relationship uses OpenFGA”, as if the tool picked the layer. It works the other way round. The shape of the question puts you at a layer, and the tool follows. You could use OpenFGA to answer a question that never depended on a relationship, and it would work, but it would be far more machinery than a role check that already gave the right answer.


What coarse-grained buys you, and what it costs

A coarse check is fast, because it’s a claim lookup or an in-memory role comparison with no external data to fetch. It’s simple to model, because the list of roles or flags is short and you can write it down. And it’s simple to audit, because “who has the admin role” is one query. A large share of any application’s authorization is legitimately coarse: internal tools used only by employees, admin panels, feature gating, anything where “can this user do X” really has the same answer for every instance of the resource.


The cost shows up when a coarse check is asked to do a fine job, which is Daniel’s story. “Any agent can see any account” was correct when it was written. Once the policy became “agents see only accounts assigned to their team”, the role check stopped being imprecise and started being wrong: it grants access the real policy says shouldn’t exist. That’s the central risk of coarse authorization. It doesn’t fail loudly when it’s too broad. It quietly over-permissions, and nobody notices until an audit, an incident or a customer does.


What fine-grained buys you, and what it costs

A fine check, at the resource or relationship layer, closes exactly that gap. It evaluates the specific instance, so “can Sara view account #4471” and “can Sara view account #9902” can have different answers even though Sara’s role never changed. That’s what least privilege and need-to-know really mean in a system that holds other people’s data, and it’s what most compliance regimes end up asking for once you read past the headline requirement. HIPAA, SOC 2 and the GDPR’s data-minimization principle all get there eventually.


The price comes in three parts. The first is latency: a resource check usually means fetching attributes, through XACML’s PIP or its equivalent, and a relationship check means walking a graph. Both are more work than comparing a role string that’s already sitting in a JWT. The second is modeling effort: someone has to decide which attributes matter, or which relationships and rewrite rules exist, and keep that model right as the product changes. It isn’t a one-off cost; it grows with the product. The third is operational surface: a PDP, a PIP or a relationship store is one more thing to deploy, monitor, keep available and reason about for consistency, none of which a role claim in a JWT ever needs.


So fine-grained is a cost you take on for a reason, not a default. Treating it as the more sophisticated and therefore more correct choice leads to overbuilt systems. A relationship graph deciding which employees can read which internal wiki pages, when the real rule is “any employee”, is pure overhead with no security gain.


Layered authorization

Production systems rarely pick one layer and stop. They stack several, cheapest first, so most invalid requests are turned away before they reach the expensive check.


flowchart LR
  accTitle: A layered authorization path that rejects cheaply first
  accDescr: The diagram shows a request flowing left to right through four stages. First, the client sends a request to an API gateway, which performs a perimeter check verifying the token or API key is valid, rejecting immediately if not. Second, the request reaches a service, which performs a module or role check verifying the caller's role permits this action type at all, rejecting if not. Third, only requests that passed both cheap checks reach a fine-grained check, either a resource attribute evaluation through a policy decision point or a relationship check through a graph engine, which is the most expensive step. Fourth, if all checks pass, the service performs the requested operation. Each layer is cheaper and rejects a larger share of invalid traffic than the layer after it.
  Client([Client request]) --> Gateway[API Gateway<br/>perimeter: valid token?]
  Gateway -- rejected --> Deny1[["Deny (401/403)"]]
  Gateway -- valid --> Service[Service<br/>module: role permits action type?]
  Service -- rejected --> Deny2[["Deny (403)"]]
  Service -- permitted --> Fine[Fine-grained check<br/>resource attrs or relationship graph]
  Fine -- denied --> Deny3[["Deny (403)"]]
  Fine -- allowed --> Op[Perform operation]
A layered authorization path. The gateway rejects unauthenticated or malformed requests immediately (perimeter). The service checks the caller's role against the minimum the action needs (module), turning away most of the remaining invalid requests cheaply. Only requests that pass both cheap layers reach the fine-grained check, a resource-attribute evaluation or a relationship Check, which is the most expensive step and is reached last on purpose.

It’s the same lesson the XACML article gave about where to put the PDP, a trade-off and not a fixed answer, applied this time to which check runs rather than where. The gateway’s check and the service’s role check exist largely to protect the expensive layer from load it doesn’t need. Rejecting a request with a bad token at the gateway costs one signature verification. Rejecting the same request at a relationship Check would cost a graph traversal for nothing. Layering cheap-first is a performance pattern with a security side effect: most of the invalid traffic an attacker or a bug produces never reaches the part of the system where slowness would hurt.


The N+1 authorization problem

Granularity choices don’t only affect single requests. They compound badly in code that renders lists, in a pattern anyone who has chased a slow page caused by an N+1 database query will recognize.


sequenceDiagram
  accTitle: The N+1 authorization problem and its bulk-API fix
  accDescr: The diagram contrasts two approaches to rendering a filtered list of fifty documents. In the naive approach, the application fetches the candidate documents once, then loops over them calling Check individually for each one, producing fifty separate authorization round-trips before the page can render, mirroring the classic N plus 1 database query problem. In the fixed approach, the application fetches the same candidates once and calls a single bulk lookup, ListObjects, which returns every allowed document ID in one round-trip instead of fifty.
  App->>App: naive: N+1 calls
  App->>DB: fetch 50 documents
  loop each document
    App->>AuthZ: Check(user, doc)
    AuthZ-->>App: allow / deny
  end
  App->>App: fixed: one bulk call
  App->>DB: fetch 50 documents
  App->>AuthZ: ListObjects(user, doc type)
  AuthZ-->>App: allowed IDs
The N+1 authorization problem. Rendering a list of 50 documents by calling Check once per document turns one page load into 50 authorization round-trips. A bulk or reverse-lookup API answers the same question, which of these can this user see, in a single call.

The fix is the database fix. Don’t ask “is this one allowed” in a loop; ask “which of these can I see” once. OpenFGA’s ListObjects and ListUsers exist for exactly this. With a resource-attribute engine, the equivalent is usually pushing the filter into the query itself, something like WHERE owner_id = :user_id OR :user_id IN (SELECT ...), instead of fetching everything and calling the PDP once per row in application code. The loop isn’t just slow. It’s a sign that you’re asking the engine the wrong question: “which of N things” is a different and cheaper question than “is this one thing”, even if it looks like N copies of the same check.


Choosing a layer per feature

Given a feature, three questions in order are usually enough, and you stop at the first one that settles it.


QuestionIf yesIf no
Does every caller either fully have or fully lack this capability, whichever resource instance is involved?Module or role. A role claim, a feature flag, a Casbin matcher on role alone.Next question
Does the answer depend only on attributes of the resource and the caller (ownership, status, department, time of day), not on a path through other resources?Resource. XACML, OPA or Cedar style policy over subject, resource and environment attributes.Next question
Does the answer depend on a relationship that can be shared, nested or delegated, through a group, a folder or an org chart, at a depth you can’t list in advance?Relationship. Graph reachability in the style of Zanzibar or OpenFGA.Reconsider: it may be a business rule, not an access-control rule

The common mistake is answering this once, at design time, and choosing a single tool for the whole application. Real systems are layered and mixed at the same time: the admin panel gate is a role check, invoice editing is a resource check on ownership and status, and “share this report with my team” is a relationship check, all in the same product. Ask again whenever a feature’s requirements change. “Agents can view any account” turning into “agents can view accounts on their team” is exactly the jump from module to resource that Daniel’s console missed.


Three features in the cashback app

Look at three features of the same cashback app side by side, because the contrast is the lesson.


Whether a user can open the rewards dashboard at all depends only on whether the account is active, which is true or false for the whole account. It’s coarse: a status claim checked at the perimeter or module layer, with no PDP call.


Whether a support agent can view a specific customer’s transaction history depends on the agent’s role and on attributes of this customer: is there an open ticket for this account assigned to this agent, is it during business hours, does the account belong to the retail partner’s dedicated team. That’s the resource layer, a PDP shaped like the XACML one evaluating subject, resource and environment on every request. It’s also where Daniel’s console should have ended up.


Whether a user can view a savings report a teammate shared with their team depends on a chain: is the user in the team, was the report shared with the team, through how many folders. That’s the relationship layer, an OpenFGA Check walking membership and sharing tuples, just like Sara’s board summary in the previous article.


Three features, three layers, one application, and each is modeled correctly at its own layer. Forcing all three onto one tool would either waste effort, with a relationship graph guarding the dashboard, or quietly under-model the rule, with a role check guarding the shared report.


Testing and running a layered system

Tests have to cover the layering, not only each layer on its own. A request should be tested against the combination it will actually go through: does the gateway reject it before the role check runs, does the role check reject it before the expensive call, and, separately, does the fine check give the right answer for cases that pass both gates. A frequent gap is testing each layer’s logic carefully and never testing that a request denied at layer two never reaches layer three. That matters for correctness and for cost, because a bug that lets denied requests fall through to the expensive layer quietly wipes out the performance benefit that layering was for.


In production, the number worth watching is where requests are being rejected. In a healthy system the vast majority of invalid traffic is turned away at the perimeter and module layers, cheaply, and only a small remainder reaches the fine layer, which is the most expensive to run and the most important to get right. If that ratio flips and most rejections happen at the relationship graph, either the coarse layers are misconfigured and letting through traffic they should have caught, or a feature has quietly become one that needed a coarse pre-filter it never got.


Recap

Granularity is a question every authorization decision answers before any tool is chosen. Decisions sit on a spectrum, perimeter, module or role, resource and relationship, and each layer has a real component behind it: a gateway, an RBAC claim, a XACML, OPA or Cedar PDP, a Zanzibar-style graph. Coarse checks are fast and simple, but when they’re too broad they don’t fail, they over-permission in silence, which is what happened to Daniel’s support console. Fine checks close that gap at a real price in latency, ongoing modeling work and operational surface, worth paying when the decision truly depends on the specific resource or relationship. Layered systems combine both, cheapest first, so the expensive layer only sees the traffic that needs it. Checking items one by one in a loop is the N+1 authorization problem, fixed the same way as the database version, with one bulk or reverse-lookup call. And the question gets asked per feature, not once for the whole system, because most real applications rightly use all four layers.



Three questions to test yourself

  1. A feature starts as “any logged-in employee can view the internal wiki” and later gains a requirement that some pages are visible only to specific teams. Using the granularity spectrum, explain which layer the feature started at and which layer it needs to move to, and why simply adding more roles would eventually break down.
  2. Explain the N+1 authorization problem in your own words, and describe why a loop of individual Check calls is not just slower but a sign of asking the decision engine the wrong question.
  3. A system checks authorization only at a central resource-layer PDP for every request, including ones an API gateway could reject for an invalid token alone. What does adding a perimeter check upstream actually save, given that the PDP would reject the same request anyway?

Hands-on exercises

  1. Classify five real decisions. For an application you use or are building, list five distinct authorization decisions it makes (a page gate, a button visibility check, a data filter, a sharing feature, an admin action) and place each on the spectrum using the three questions above. Note which are currently over-modeled or under-modeled compared with your classification.
  2. Design a layered path for one feature. Pick one fine-grained decision from the first exercise and design its full request path: what the perimeter check rejects, what the role check rejects and what the fine check evaluates. Estimate roughly what share of invalid requests each layer should catch.
  3. Find (or create) an N+1 authorization bug. In a codebase you can access, look for a list-rendering path that calls an authorization check inside a loop. If you find one, sketch how you’d replace it with a bulk or reverse-lookup call. If you don’t, write a small example, pseudocode is fine, that shows the pattern and its fix.

Frequently asked questions

What is the actual difference between coarse-grained and fine-grained authorization?

Coarse-grained authorization decides access at a broad boundary that does not depend on which specific resource is involved: a role check ('is this user an admin'), a feature flag, a module gate. Fine-grained authorization decides access per resource, per field or per relationship, such as 'can this specific user edit this specific invoice', which depends on data the decision engine has to look up, not just on who the caller is. They are not different technologies; they are different amounts of context a decision needs before it can be trusted.

Is fine-grained authorization always more secure, so I should always use it?

No. More granularity is not free, and applying it where it is not needed adds latency, data-modeling work and operational surface for no security benefit. If a decision genuinely does not depend on which resource is involved (an admin panel gate, a feature flag), a coarse role check is not a weaker version of a fine-grained one; it is the correct model for that decision. The skill is matching granularity to what the decision actually depends on, not maximizing it everywhere.

How do I decide which granularity a given feature needs?

Ask one question: does the correct answer depend on which specific resource, record or relationship is involved, for this specific caller? If the answer is the same for every instance of that resource type regardless of who owns it or who it's shared with, it's coarse-grained, a role or module check. If the answer changes per instance, per document, per account, per relationship, it needs fine-grained, resource-level evaluation. Ask it per feature, not once for the whole system; most real systems are a mix.

What is the N+1 authorization problem?

It's what happens when a list-rendering code path checks authorization for each item individually in a loop: list 50 documents, then call Check() 50 times to filter them, turning one user action into dozens of authorization round-trips. It's the same anti-pattern as the classic N+1 database query. The fix has the same shape as the database fix: use a bulk or reverse-lookup API (OpenFGA's ListObjects, a batched Check, or a single database-level filter) that answers 'which of these am I allowed to see' in one call instead of one per item.

Can coarse-grained and fine-grained authorization coexist in the same request path?

Yes, and in production systems they usually do. This is called layered or defense-in-depth authorization. A cheap coarse check (is this caller even a member of this organization, does their role permit this action type at all) runs first and rejects most invalid requests immediately; only requests that pass it reach a more expensive fine-grained check (does this specific relationship exist for this specific resource). The coarse layer protects the fine layer from load it doesn't need to bear, and the fine layer catches what the coarse layer structurally cannot.

How does this relate to XACML, OPA/Cedar/Casbin, and Zanzibar from the earlier articles?

Those articles gave you the components (PEP, PDP, PAP), the languages (Rego, Cedar, Casbin) and one specific fine-grained model (the relationship graphs of Zanzibar and OpenFGA). This article sits above all of them: given a feature, which of those tools, or which combination, actually fits, based on how much the access decision depends on specific resource data? A role check might be a Casbin matcher or a JWT claim; a fine-grained check might be an OpenFGA Check call. The granularity question comes first, and it decides which tool you reach for.