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 decision framework for choosing the right granularity per feature instead of by default.
The question the last two articles skipped
The previous 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-reachability question was the right shape and covered how to model and query it. Neither asked the question that actually comes first, on every single feature you build: how much does this access decision need to know about the specific resource involved, for this specific caller, before it can be trusted?
That question has an answer that ranges continuously from “not at all” to “everything about a specific relationship between this exact user and this exact resource” — and where a given decision falls on that range is what this article calls granularity. Get it wrong 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.” Get it wrong in the other direction and you underbuild: a single is_admin boolean guarding a feature that was actually supposed to differ per customer, per document, per team. Both mistakes are common, and both are avoidable once granularity is a deliberate choice instead of a default.
A spectrum, not a binary
“Coarse-grained” and “fine-grained” sound like two boxes, but production systems make authorization decisions at several distinct layers, each answering a differently-shaped question with a different cost:
Four layers are worth naming explicitly, because each maps to a real architectural component you’ve already met or will meet:
- Perimeter. Network ACLs, an API gateway checking a key or a bearer token’s validity, a WAF rule. The question is “is this caller allowed to talk to this system at all” — it knows nothing about resources, roles, or relationships, and it shouldn’t; that’s not its job.
- Module / role. “Is this user an admin,” “can support agents see the billing module,” a feature flag gating a whole capability. This is classic RBAC — the decision depends on who the caller is (their role, their 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. - Resource. “Does this user own this specific invoice,” “is this document’s status ‘draft’ and was it created by this user.” The decision now depends on attributes of a specific resource instance, evaluated against the caller — this is where XACML’s PDP, OPA/Rego, and Cedar earn their keep, matching a request’s subject/resource/action/environment against policy.
- Relationship. “Was this document shared with this user, directly or through a group, through a folder, at any depth.” The decision depends on a specific edge or path existing between this exact subject and this exact object — Zanzibar’s and OpenFGA’s entire reason to exist.
What coarse-grained buys you, and what it costs
A coarse-grained check — perimeter or module/role layer — is fast (a claim lookup or an in-memory role comparison, no external data fetch), simple to model (a short, enumerable list of roles or flags), and simple to audit (“who has the admin role” is one query). Most of an application’s authorization surface is legitimately coarse: internal tooling used only by employees, admin panels, feature gating, anything where the answer to “can this user do X” really is the same for every instance of the resource in question.
The cost shows up when a coarse check is asked to do a fine-grained job. Consider an internal support console where any agent with the support-agent role can view any customer’s account. That’s coarse by design, and it’s fine — until the product grows a requirement that agents should only see accounts assigned to their team, or accounts they have an open ticket for. Now the role check is wrong, not imprecise: it grants access a real policy says shouldn’t exist, silently, because the check was never asked “which account.” This is the central risk of coarse-grained authorization: it doesn’t fail loudly when it’s too broad — it just quietly over-permissions, and the gap is invisible until an audit, an incident, or a customer notices.
What fine-grained buys you, and what it costs
A fine-grained check — resource or relationship layer — closes exactly that gap: it evaluates against 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. This is what least-privilege and need-to-know actually require in systems handling other people’s data — a fine-grained model is what most compliance regimes (HIPAA, SOC 2, GDPR’s data-minimization principle) end up demanding once you read past the headline requirement into the access-control detail.
The cost is real and multi-part. Latency: a resource-level check usually means fetching attributes (XACML’s PIP) or, for a relationship check, traversing a graph — both are more work than comparing a role string already sitting in a JWT. Modeling effort: someone has to define what attributes matter (resource-level) or what relationships and rewrite rules exist (relationship-level), and keep that model correct as the product evolves — this is not a one-time cost, it’s ongoing maintenance that scales with feature complexity. Operational surface: a PDP, a PIP, or a relationship store is a new thing to deploy, monitor, keep available, and reason about consistency for — problems a role check embedded in a JWT simply does not have.
Layered authorization: combining coarse and fine in one request
Production systems rarely pick one layer and stop — they compose several, cheapest first, so that most invalid requests are rejected before reaching the most 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. A note beneath the diagram states that 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]
This is not a new idea invented for this article — it’s the same “PDP placement is a trade-off, not a fixed answer” lesson from the XACML article, now applied to which check runs, not just where. The gateway’s perimeter check and the service’s role check both exist specifically to protect the expensive fine-grained layer from load it doesn’t need to bear: rejecting a request with an invalid token at the gateway costs a token-signature check; rejecting the same bad actor at a relationship-graph Check call would cost a graph traversal for no reason. Layering cheap-first is a performance pattern with a security side effect — most of an attacker’s or a bug’s invalid traffic never reaches the part of the system expensive enough to matter if it’s slow.
The N+1 authorization problem
Granularity choices don’t only affect single requests — they compound badly in list-rendering code, in a pattern that should feel familiar if you’ve ever debugged a slow page caused by a database N+1 query:
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.
Note over App: Naive: N+1 calls
App->>DB: fetch 50 documents
loop each document
App->>AuthZ: Check(user, doc)
AuthZ-->>App: allow / deny
end
Note over App: Fixed: one bulk call
App->>DB: fetch 50 documents
App->>AuthZ: ListObjects(user, doc type)
AuthZ-->>App: allowed IDsThe fix mirrors the database fix exactly: don’t ask “is this one allowed” in a loop, ask “which of these am I allowed to see” once. OpenFGA’s ListObjects and ListUsers APIs (from the fine-grained authorization article) exist specifically for this; a resource-attribute engine’s equivalent is usually pushing the filter into the database query itself — WHERE owner_id = :user_id OR :user_id IN (SELECT ...) — rather than fetching everything and filtering row-by-row in application code against a PDP call per row. Either way, the principle is the same: granularity is a property of the question, and “which of N things” is a different, cheaper question than “is this one thing,” even though it looks like N copies of the same check.
A decision framework
Given a feature, ask these questions in order, and stop at the first one that resolves it:
| Question | If yes → | If no → |
|---|---|---|
| Does every caller either fully have or fully lack this capability, regardless of which resource instance is involved? | Module/role. A role claim, a feature flag, a Casbin matcher on role alone. | Continue |
| Does the answer depend only on attributes of the resource itself and the caller (ownership, status, department, time of day) — not on a path through other resources? | Resource. XACML/OPA/Cedar-style policy evaluated against subject/resource/environment attributes. | Continue |
| Does the answer depend on a relationship that can be shared, nested, or delegated — through a group, a folder, an org chart — at a depth you can’t enumerate in advance? | Relationship. Zanzibar/OpenFGA-style graph reachability. | Reconsider — the decision may not need authorization logic at all (e.g., it’s a business rule, not an access-control rule) |
Worked example: granularity across one cashback app
Return to the cashback company from the earlier articles and look at three features side by side, because the contrast is the whole lesson:
“Can this user open the app’s rewards dashboard at all?” — depends only on whether the account is active, a fact true or false for the whole account, nothing resource-specific. Coarse: a role/status claim, checked at the perimeter or module layer, no PDP call needed.
“Can this support agent view a specific customer’s transaction history?” — depends on the agent’s role and attributes of this specific customer (is there an open ticket assigned to this agent for this account, is it during business hours). This is resource-layer: a XACML-shaped PDP evaluating subject, resource, and environment attributes per request, exactly the pattern the XACML article’s fintech example walked through.
“Can this user view a savings report a teammate shared with their team?” — depends on a chain: is the user a member of the team, was the report shared with the team, at what depth. This is relationship-layer: an OpenFGA Check walking membership and sharing tuples, exactly the pattern the fine-grained authorization article’s Sara example walked through.
Three features, three layers, one application — and each one is correctly modeled at the layer it’s at. Forcing all three onto a single tool (a relationship graph for the dashboard gate, or a role check for the shared report) would either waste effort or silently under-model the access rule.
Testing and operating a layered system
Testing has to account for the layering, not just each layer in isolation: a request should be tested against the combination it will actually experience — does the gateway reject it before the role check even runs, does the role check reject it before the expensive fine-grained call, and separately, does the fine-grained call give the right answer for cases that pass both earlier gates. A common gap is testing each layer’s logic thoroughly while never testing that a request denied at layer two never reaches layer three at all — which matters for both correctness and cost, since a bug that lets denied requests fall through to the expensive layer erodes exactly the performance benefit layering exists to provide.
Operationally, the metric worth watching is where in the pipeline requests are being rejected. A healthy system rejects the overwhelming majority of invalid traffic at the perimeter and module layers — cheap, fast rejections — and only a small remainder reaches the fine-grained layer, which is both the most expensive to run and the most important to get right. If that ratio inverts — if most rejections are happening at the expensive relationship-graph layer — it’s a signal either that the coarse layers are misconfigured (too permissive, letting through traffic the cheap checks should have already caught) or that a feature has quietly become one that needed a coarser pre-filter it never got.
Recap
Granularity is the question every authorization decision answers before any tool gets chosen:
- Authorization sits on a spectrum, not a binary — perimeter, module/role, resource, and relationship each answer a differently-shaped question, and each has a real architectural component behind it (gateway, RBAC claim, XACML/OPA/Cedar PDP, Zanzibar/OpenFGA).
- Coarse-grained is fast and simple but fails silently when it’s too broad — it doesn’t error, it over-permissions, and the gap is often invisible until an audit or incident surfaces it.
- Fine-grained closes that gap at a real cost — latency, ongoing modeling effort, and new operational surface — that is worth paying exactly when the decision genuinely depends on the specific resource or relationship, not by default.
- Layered architectures combine both, cheapest checks first, so the expensive fine-grained layer only ever sees the traffic that actually needs it.
- The N+1 authorization problem is what happens when per-item checks replace a single bulk or reverse-lookup call — the same fix shape as the classic database N+1 problem.
- Apply the granularity question per feature, not once for the whole system — most real applications correctly mix all four layers.
Three questions to test yourself
- A feature starts as “any logged-in employee can view the internal wiki” and later gains a requirement that some pages should only be visible to specific teams. Explain, using the granularity spectrum, which layer the feature started at and which layer it needs to move to — and why simply adding more roles would eventually break down.
- 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 symptom of asking the decision engine the wrong-shaped question.
- A system currently checks authorization only at a central resource-layer PDP for every request, including ones an API gateway could reject based on an invalid token alone. What does adding a perimeter-layer check upstream actually save, given that the resource-layer check would eventually reject the same request anyway?
Hands-on exercises
- 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 classify each on the granularity spectrum using the decision framework’s three questions. Note which ones are currently over-modeled or under-modeled relative to your classification.
- Design a layered path for one feature. Pick one fine-grained decision from the exercise above and design the full layered request path for it: what does the perimeter check reject, what does the module/role check reject, and what does the fine-grained check evaluate. Estimate, roughly, what share of invalid requests each layer should be catching.
- Find (or create) an N+1 authorization bug. In a codebase you have access to, look for a list-rendering code 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 find one, write a small example (pseudocode is fine) that demonstrates 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 — 'can this specific user edit this specific invoice,' which depends on data the decision engine has to look up, not just who the caller is. The two 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 burden, 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 this article teaches is matching granularity to what the decision actually depends on, not maximizing granularity 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. Apply that question 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, the exact same anti-pattern as the classic N+1 database query problem. The fix is 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 the coarse gate 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 (Zanzibar/OpenFGA's relationship graphs). This article is the layer 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 determines which of the previous articles' tools you reach for.