İçeriğe geç
wedevit

August 26, 2026 · 9 min read · software

İlhan Buğra Aslan

Who can see what? Designing authorization: roles, permissions, RBAC and ABAC


Authorization is a separate subsystem that starts where the login screen ends, and it has to answer three questions at once: which actions can this user perform, which records can they touch, and which fields of those records can they see. Most projects model only the first one. The second usually arrives in month six as "the branch manager should only see their own branch's orders", gets solved with a single condition on one endpoint, and then goes missing on the other thirty. Broken access control is still the number one category in the OWASP Top 10 2025, mapped to 40 CWEs with more than 1.8 million occurrences and over 32,000 CVEs in the contributed data. This post is about building authorization as a model up front, not as a feature bolted on later.

Authentication says who you are, authorization says what you may do

The login flow runs once and produces an identity. Authorization runs on every request, because the answer depends on the request itself: the same user may read order 1024 and not order 1025. Collapsing the two into one box is a common mistake, since buying an identity provider does not mean you bought authorization (whether to build or buy authentication is a separate decision).

OAuth scopes cause the same confusion. A scope limits what a client application may ask for on the user's behalf. A token carrying orders:read says nothing about which orders that particular user may read. That question is answered on the resource side, as close to the data as possible. Putting roles inside the token is a related trap: once a role is baked into a signed token, it keeps working for the lifetime of that token even after you revoke it.

Three separate questions: action, record, field

OWASP's Application Security Verification Standard reached version 5.0.0 on 30 May 2025 and gives authorization its own chapter. The split it asks for is the split that works in practice:

  • Action level: may this user invoke this function at all? The failure mode is broken function level authorization, the classic case being a regular user calling an admin endpoint.
  • Record level: which records may they run that function against? ASVS 8.2.2 asks for this explicitly, to mitigate IDOR and BOLA.
  • Field level: which fields of the same record may they read, and which may they write? ASVS 8.2.3 ties this to BOPLA.

The third one gets skipped the most. Support staff probably need to open a customer record without seeing the bank account number on it. The write side is sneakier: if a user can update their own profile and the request body is mass-assigned onto the model, adding a role field to that request turns into privilege escalation. We covered the API-side equivalents in detail in API security and the OWASP API Security Top 10.

Collect permissions, not roles

RBAC has an actual standard behind it. ANSI/INCITS 359 was first published in 2004, republished in 2012 and reaffirmed in 2022; the NIST role-based access control model was adopted into it. The structure it defines is plain: permissions attach to roles, users are assigned to roles, and a role stands for a job function.

Here is where implementations go sideways. Every new request becomes a new role: "warehouse manager", then "Ankara warehouse manager", then "Ankara warehouse manager without pricing". Six months later you have forty roles and nobody can say what any of them do. NIST SP 800-162 calls this role explosion and names the cause clearly: role assignments are based on fairly static organizational positions, so pushing dynamic decisions into roles produces a pile of ad hoc roles with tiny membership.

A rule that holds up: keep the number of roles equal to the number of real job functions. Differences in scope ("which branch", "which customer", "up to what amount") belong in the data and in user attributes, not in role names. In code, replace checks like if (user.role === "admin") with permission checks like if (user.can("order.refund")). Adding a role then becomes configuration instead of a code change, and you stop shipping a deploy every time sales promises a new permission bundle.

"Only their own branch" is a scope, not a role

When that request lands, there are two wrong answers. The first is filtering in the UI. The second is fetching the data and filtering it in memory, pulling a thousand rows and rendering fifty. The second one looks like it works, right up until someone writes a second endpoint, an export button or a report that returns the same data without the filter.

The right place is the query. The user's scope belongs in the where clause, not in an optional layer above it. One of OWASP's own recommendations for A01 says it in a sentence: the model should enforce record ownership rather than letting a user read or modify any record. Watch the performance side too. An application that fires a separate authorization lookup per row produces N+1 queries on the first list screen it renders. If you can express scope as one join or one predicate, you get correctness and speed from the same change.

Row-level security in the database can be a useful last line of defence, though not on its own, and its defaults surprise people. Enable RLS on a table with no policies and PostgreSQL shows no rows at all, which is the good news. The bad news: the table owner normally bypasses policies entirely unless you add FORCE ROW LEVEL SECURITY, so if your application connects as the owner, the policies you wrote are quietly inert. We went through this in multi-tenant SaaS architecture.

When RBAC runs out: attributes and relationships

Some decisions do not fit into a role. "Can approve expense claims for their own team, but not above 50,000, and not outside business hours" contains three different attributes. That is what NIST SP 800-162 describes: ABAC evaluates attributes of the subject, the object, the requested operation and, in some cases, environment conditions against a policy. You gain flexibility and you pay for it in attribute and policy management. If the attributes are wrong the decisions are wrong, which means keeping "which user belongs to which branch" accurate is now a security job, not a data-entry job.

A third model is built on relationships. "I own this folder, so I can share the files inside it" or "I manage this branch, so I see the orders under it" are decisions that walk a graph. The reference work here is Google's Zanzibar paper from USENIX ATC 2019: a single authorization system holding trillions of access control lists, answering millions of queries per second, with 95th-percentile latency under 10 milliseconds and availability above 99.999% across three years of production use. Open-source descendants exist today. OpenFGA moved to CNCF incubating status in November 2025, SpiceDB came out of the same paper, and OPA and Cerbos do a comparable job on the policy side.

The decision criterion is narrow. If your authorization rules change faster than you deploy, or your customers define their own roles, a dedicated authorization engine earns its keep. If neither is true, one well-factored authorization module inside your codebase is plenty. Adding infrastructure does not reduce the complexity of your rules, it relocates it.

Decide in one place, enforce on every request

A healthy setup has two parts: the place that makes the decision and the place that enforces it. The NIST ABAC guide draws that line, and OWASP recommends the same thing for A01: implement the mechanism once, then reuse it throughout the application. In practice this means the decision lives behind one callable interface rather than being sprinkled across conditionals.

Route-level middleware is half the job. Locking everything under /admin to an admin role handles the action level and does nothing for the record level. The record check has to happen in the layer that touches data, immediately after the object is loaded. Make the default deny, too: a newly written endpoint that matches no permission should be closed, not open. OWASP puts it in five words, "except for public resources, deny by default". Write down a precedence rule as well, so that when two policies disagree an explicit deny always wins and nobody has to argue about it during an incident.

Hiding a button in the UI is not authorization

That sounds obvious, yet the most common real-world failure is a variant of it. Removing the button, hiding the menu item, redirecting the page: all of these happen on the client, and the client belongs to the attacker. OWASP is blunt about it. Access control is only effective in trusted server-side code or serverless APIs, where the attacker cannot modify the check or its metadata. Making identifiers unguessable (UUIDs instead of incrementing integers) raises the cost of discovery and is not a substitute for the check.

The joint advisory CISA, the NSA and Australia's ACSC published on 27 July 2023 (AA23-208A) is dedicated to this exact class of bug. Its assessment of IDOR flaws comes down to three points: they are common, they are hard to prevent outside the development process, and they can be abused at scale. Its recommendation is one line long. Perform authentication and authorization checks on every request that accesses, modifies or deletes sensitive data.

Changing one digit: 885 million documents

First American Financial is the clearest illustration of the cost. The story broke in May 2019. The company sent customers links to their transaction documents, and anyone who changed a digit in the link could read documents from someone else's transaction. Roughly 885 million records going back to 2003 were exposed that way, including bank account numbers, mortgage and tax records, Social Security numbers and driver's licence images. The company had found the flaw in 2018 and it stayed unfixed for months afterwards.

New York's financial regulator announced a $1 million settlement on 28 November 2023, citing failures that included access controls and identity management alongside risk assessment. The technical lesson is small and the management lesson is large. Authentication was in place. Sessions worked. Traffic was encrypted. The only missing piece was the question "does this document belong to this user", and nothing in the system was asking it.

Impersonation, support access and service-to-service calls

ASVS 8.3.3 asks for something specific: access to an object should be based on the permissions of the originating subject, not those of an intermediary or a service acting on their behalf. That requirement targets two common patterns. The first is the "log in as this user" button in your support tool. The feature is legitimate, but it should be time-boxed, require a reason, record both the real and the impersonated identity in the audit log, and disable destructive operations while it is active.

The second is internal service calls. If your services call each other with a key that can do anything, every check behind that service becomes decorative: once a request reaches it, the service acts with its own permissions rather than the user's. The classic name for this is the confused deputy problem. The fix is to carry user context along the whole call chain and make the final decision in the service closest to the data.

How long does revocation take to land?

Authorization decisions get cached everywhere: roles embedded in tokens, decision caches, read-replica lag. The consequence is that removing a permission does not take effect the moment you click save. The Zanzibar paper gives this failure a name, the "new enemy" problem. When the causal order between access control list updates and content updates is not respected, two bad things follow: an old list gets applied to new content, or access you already revoked becomes valid again. Google solves it by timestamping every update and handing clients a consistency token they call a zookie.

At your scale the fix is simpler, but the decision is still yours to make. Keep token lifetimes short, invalidate sessions when a role changes, and for money movement or deletion read the decision from the source instead of a cache. The real deliverable is a number: in the worst case, how many seconds after revocation does a permission stop working? If there is no written answer to that, you do not have revocation, you have eventual hope.

Untested authorization is no authorization

Authorization is where automated scanners are weakest, because a scanner has no idea who is supposed to see what. That is exactly what the CISA advisory means by "hard to prevent outside the development process". The work lands in your test suite.

The pattern that works is a matrix test: at least two tenants, at least two roles per tenant, and for every endpoint that requires a permission, an expectation for what happens when the wrong user calls it. What the test verifies is not the absence of a 200, but the right kind of refusal: 404 for someone else's record, 403 for an operation inside your scope that you lack permission for. These belong in the cheapest layer of your test automation strategy, and they turn a missing check on a new endpoint into a failing build rather than a support ticket. Logging closes the loop. OWASP recommends logging access control failures and alerting administrators when appropriate, and for good reason: an account collecting 403s in sequence is not a confused user, it is somebody trying doors (log management and monitoring is where that signal gets read).

A first step that fits in this week

Start with five questions. One: are your authorization rules written down anywhere, or do they exist only inside the code? A single table listing roles and permissions row by row is half a day of work for most teams and surfaces the first contradiction immediately. Two: where does the record-level check happen, in the query or in memory? Three: how many roles do you have, and how many of them are real job functions rather than copies spawned by one customer request? Four: can support staff enter a user's account, and if so, is that logged? Five: when you revoke a permission, how many seconds pass before it actually stops working?

Four of those five get answered in one sitting. Once you have the answers, the order of work picks itself: push record-level checks down into the query, then simplify roles back to job functions, and only then discuss infrastructure like a dedicated authorization engine. Doing it in reverse, choosing a tool before fixing the model, rebuilds the same tangle somewhere more expensive.


Need help with this topic?

get in touchall posts