Why Most Web Apps Get Access Control Wrong From Day One
Access control is almost never the first thing a team builds. It's the thing they add when someone asks "wait, can any user see anyone else's data?" The answer, in a distressing number of cases, is yes.
This isn't a criticism of developer skill. It's a critique of the development process. Access control failures aren't random. They follow predictable patterns that emerge from how authorization is approached at the start of a project.
The "We'll Add It Later" Pattern
The most common access control mistake is treating authorization as something to retrofit rather than design upfront. The reasoning is understandable: in the early stages of a product, you have a small team, a limited user base, and authorization complexity that seems manageable with a simple admin flag.
That changes as the product grows. More user types appear. Different users need different access. The simple admin flag becomes a set of overlapping conditionals. By the time the team recognizes they need a formal authorization model, they're looking at hundreds of endpoints, thousands of queries, and conditional logic scattered through files they wrote years ago.
Retrofitting authorization into a mature codebase is one of the most expensive engineering projects a team can undertake. Every query needs a scope check. Every endpoint needs a middleware. Every data structure might need new ownership fields. The cost is proportional to the size and age of the codebase.
OWASP's broken access control documentation identifies this exact pattern as the source of the majority of authorization vulnerabilities: not a single elegant attack, but an accumulation of missing checks in a system that was never designed with a formal authorization model.
The "UI Access Control Is Real Access Control" Pattern
Many applications enforce access control at the UI layer: buttons are hidden, menu items are removed, sections are conditionally rendered based on user role. The backend receives requests from authenticated users and trusts that the UI is doing the gating.
This is not access control. It's user experience. Any user with a browser dev tools panel or a basic API client like curl can make requests directly to API endpoints. The UI doesn't route those requests. The server does. If the server doesn't enforce authorization, the UI enforcement is cosmetic.
The Wikipedia overview of role-based access control is explicit about this: access control must be enforced at the layer that can't be bypassed. That's the server. For web applications, the backend API is the enforcement layer.
The fix is always the same: every endpoint that returns or modifies data needs server-side authorization. Not every endpoint needs a complex check, but every endpoint needs some check.
The Scattered Conditional Logic Pattern
In codebases that didn't start with a formal authorization model, authorization logic tends to accumulate as scattered conditionals:
if (user.is_admin || user.id === resource.owner_id) {
// allow
}
This looks innocent at first. It becomes a problem when: - The same condition is implemented slightly differently in different files - A new role appears that should have some of the same permissions as admin but not all - A security review asks "who can modify resource X?" and there's no single place to check
CASL was built specifically to solve this problem in JavaScript applications. Instead of scattered conditionals, you define permissions once and reference them everywhere. The codebase is smaller, more consistent, and auditable. Casbin provides a similar solution for teams that prefer externalized policy definitions.
The architectural principle is simple: authorization logic should live in one place, be defined once, and be referenced by everything that needs to enforce it. Any other approach accumulates inconsistency.
The Missing Data-Layer Scope Pattern
Route-level authorization middleware is the most common authorization implementation: a function that runs before the handler and checks whether the user has a role that permits access. This is necessary but not sufficient.
Route-level checks answer "is this user allowed to reach this endpoint?" They don't answer "is this user allowed to see this specific piece of data?" Those are different questions.
A user who has the editor role and can reach the /articles endpoint might still be able to retrieve articles belonging to other editors if the query that fetches articles doesn't filter by the requesting user's ownership. Route checks aren't substitutes for query-level authorization.
OWASP's broken access control examples consistently include cases where route checks are in place but data-layer scoping is missing. The fix is to build ownership checks into the query itself, not to filter results after the fact.
The Untested Authorization Pattern
Authorization is one of the most undertested aspects of most web applications. Unit tests verify that handlers return the right data. Integration tests check that the API behaves correctly for known good requests. But authorization testing requires adversarial thinking: what happens if a user with the wrong role makes this request? What happens if they change the record ID to one that belongs to another user?
These tests are easy to write once you know to write them. The problem is that most teams don't write them because they didn't design the authorization model as something testable.
The guide How to Implement Role-Based Access Control in Web Applications covers the centralized permission model design that makes authorization testable as a standalone concern, separate from the application logic that uses it.
The Undertested Authorization Pattern
Authorization is one of the most undertested aspects of most web applications. Unit tests verify that handlers return the right data for valid requests. Integration tests check that the API behaves correctly for known good inputs. But authorization testing requires adversarial thinking: what happens if a user with the wrong role makes this request? What happens if they change the record ID to one that belongs to another user?
These tests are straightforward to write once you have a centralized permission model. They become difficult when authorization logic is scattered across the codebase in dozens of files with no single interface to test against. Treating authorization as a testable concern at design time makes the system measurably more secure and easier to audit.
Starting Correctly
The correct approach isn't to build a complex authorization system on day one. It's to build a simple system that's designed for extension.
Define a roles array on the user object. Define a permission map that associates roles with allowed actions. Build a single authorization function that checks the map. Apply that function consistently at every endpoint and every data query.
When requirements grow, you extend the permission map and the condition logic. You don't scatter new conditionals across the codebase.
JWT.io documentation covers how to handle role claims in tokens correctly. Node.js middleware patterns make it straightforward to apply authorization consistently across Express routes. Neither requires a sophisticated framework for simple applications.
The cost of designing authorization correctly at day one is a few hours of design work and a few hundred lines of authorization code. The cost of not doing it compounds for years.
For teams that want help getting authorization right from the start or auditing an existing system, 137Foundry web development services includes this kind of technical engagement as part of application development work.
Photo by StartupStockPhotos on Pixabay
Photo by panumas nikhomkhai on Pexels
Comments
Post a Comment