
Why your Access Control is failing
Broken access control is the number one web app security risk according to OWASP. 94% of applications they tested had some form of broken access control. This means us software engineers are getting the very fundamentals wrong.
Successful exploitation of broken access control allows attackers to bypass restrictions and gain unauthorized access to sensitive data, functionalities, and or even administrative privileges. This can lead to severe consequences like data breaches, financial loss, and reputational damage.
About OWASP for those interested
OWASP is a non-profit foundation. This independence means their resources, tools, and guidelines are unbiased and not tied to any commercial product or service. Their open-source nature encourages broad community participation and scrutiny, leading to robust and well-vetted outputs.
Their strength lies in its global community of security experts, developers, and researchers. This collective knowledge and experience contribute to the quality and relevance of their projects and recommendations. The OWASP Top 10, for example, is based on real-world data and the consensus of security professionals worldwide.
They have some pretty great resources I have been reading over the years and I recommend you check them out.
What are the runner up security risks?
While broken access control takes the top spot, it’s important to be aware of other significant threats. Here are a couple of other major risks identified by OWASP:
#2: Cryptographic Failures
Often categorized under “Sensitive Data Exposure,” cryptographic failures occur when sensitive information is not properly protected. Improper implementation or the use of flawed cryptography can lead to the exposure of highly sensitive data, including user passwords, API keys, credit card information, and personal identification details. Common pitfalls include relying on weak or outdated encryption algorithms, improper key management practices (like hardcoding keys or not rotating them), and, critically, transmitting sensitive data in plaintext without any encryption at all.
#3: Injection
Injection flaws are a well-known category of vulnerabilities that allow attackers to send malicious data to an application, typically through user-supplied input fields. This malicious data is then misinterpreted and executed by the system as if it were legitimate commands or queries. Common and dangerous types include SQL injection (where attackers manipulate database queries), OS command injection (allowing execution of operating system commands), and Cross-Site Scripting (XSS) (where malicious scripts are injected into web pages viewed by other users).
And more… Beyond these, the OWASP Top 10 frequently includes other critical risks such as Security Misconfiguration (e.g., default credentials, unnecessary features enabled), Vulnerable and Outdated Components (using software with known vulnerabilities), and Identification and Authentication Failures (allowing attackers to compromise user identities or session management). Each of these represents a significant attack surface that requires careful attention.
What are the types of Access Control?
Access control is a broad concept, and numerous models and mechanisms exist to enforce it. Many of these have specific applications, some more relevant to network security or operating systems than directly to typical web application development. However, understanding how they’re typically categorized is interesting and useful.
Approach/Model | Description | Key Characteristics | Common Use Cases |
---|---|---|---|
Discretionary Access Control (DAC) | The resource owner determines access permissions. Users who own resources can grant or revoke access to other users. | Decentralized control, flexible, owner-centric. | File systems, sharing documents, small-scale environments where users manage their own data. |
Mandatory Access Control (MAC) | Access decisions are centrally controlled by a security policy administrator and enforced by the system. Users cannot override these policies. | Centralized, highly restrictive, based on security labels (e.g., clearance levels). | Military systems, government agencies, high-security environments requiring strict data confidentiality and integrity. |
Role-Based Access Control (RBAC) | Access permissions are assigned to roles, and users are then assigned to these roles. Permissions are based on job functions or responsibilities within an organization. | Centralized or decentralized role management, simplifies user permission management, principle of least privilege. | Most enterprise applications, business systems where users have defined job functions. |
Attribute-Based Access Control (ABAC) | Access decisions are based on attributes assigned to users, resources, and the environment. Policies define what combinations of attributes allow access. | Fine-grained, dynamic, context-aware, flexible policies based on multiple factors (user attributes, resource attributes, environmental conditions). | Complex systems requiring dynamic access decisions, IoT environments, cloud services, data-centric security. |
Rule-Based Access Control (RuBAC or RB-RBAC) | Access is granted or denied based on a set of predefined rules established by a system administrator. These rules often take the form of “if-then” statements. | Can be similar to ABAC but often simpler, focused on specific conditions or triggers. | Network firewalls, specific application workflows, situations with clearly definable conditional access. |
Policy-Based Access Control (PBAC) | Similar to ABAC, access is determined by evaluating policies against a set of attributes. It emphasizes a centralized policy management and decision point. | Centralized policy definition and enforcement, can be very granular and dynamic, supports complex policy logic. | Large enterprises, systems with complex regulatory requirements, dynamic environments. |
Relationship-Based Access Control (ReBAC) | Access decisions are based on the relationships between entities (e.g., users and resources, or users and other users). | Considers connections and affiliations, suitable for graph-like data structures and social networks. | Social media platforms, collaborative tools, systems where connections between entities determine access rights. |
Access Control Lists (ACLs) | A list of permissions attached to an object (e.g., a file or a network resource). The ACL specifies which users or system processes are granted access to objects, as well as what operations are allowed. | Resource-specific, can be granular but may become complex to manage at scale if not combined with other models. | Operating systems, file servers, network devices, databases. Often used as an underlying mechanism for other models. |
Context-Dependent Access Control | Access is restricted based on the current state of the application or the user’s interaction with it. Prevents users from performing actions in an incorrect sequence. | State-aware, workflow-enforcing, considers the application’s current context or user session status. | Multi-step processes like e-commerce checkouts, transactional systems, workflows requiring specific sequences. |
Hierarchical Access Control | Permissions are organized in a tree-like structure. Higher-level entities often inherit permissions from, or grant permissions to, lower-level entities. | Structured, can simplify management in organizations with clear hierarchies. | Organizational structures, file systems with nested folders. |
Identity-Based Access Control (IBAC) | Access decisions are based solely on the identity of the user (e.g., username). This is a basic form and often a component of other models. | Simplistic, identity-focused. | Basic authentication scenarios, often combined with other models for authorization. |
History-Based Access Control | Access decisions consider the past activities or history of a user or system. | Adaptive, considers past behavior. | Fraud detection systems, adaptive security systems where past actions influence future access rights. |
Which of these do we actually use?
For the practical purposes of building robust web applications, a combination of models often proves most effective. In this article, we will primarily focus on an approach that blends Role-Based Access Control (RBAC) and Attribute-Based Access Control (ABAC). This hybrid strategy is becoming increasingly common due to its flexibility and granularity. I’ll also point out a few nuances in my preferred implementation. Additionally, Access Control Lists (ACLs) are another type we frequently use, particularly for more granular control scenarios like managing access to closed beta features or specific resources.
What are the Access Controls attack vectors?
To effectively defend against access control vulnerabilities, it’s crucial to understand how attackers attempt to exploit them. Here are some common attack vectors for interest sake.
Category | Attack Vector Type | Description | Common Exploitation Techniques/Examples |
---|---|---|---|
Credential-Based Attacks | Compromised Credentials | Gaining access using stolen, leaked, or guessed user login information (usernames and passwords). | Phishing, malware (keyloggers, spyware), data breaches, social engineering, password spraying. |
Brute Force Attacks | Systematically attempting all possible password combinations until the correct one is found. | Dictionary attacks, using automated tools to try common passwords or random character strings. | |
Credential Stuffing | Using lists of compromised credentials (username/password pairs) obtained from data breaches to attempt logins on other systems, exploiting password reuse. | Automated scripts trying breached credentials against multiple services. | |
Weak/Default Credentials | Exploiting easily guessable passwords, default vendor passwords that haven’t been changed, or common password patterns. | Trying “admin/admin”, “password123”, default device passwords. | |
Privilege Escalation | Vertical Privilege Escalation | An attacker with lower-level privileges gains access to functionalities or data reserved for higher-level users (e.g., a regular user accessing admin functions). | Exploiting software vulnerabilities, misconfigurations, manipulating URLs or API requests to access restricted functions. |
Horizontal Privilege Escalation | An attacker gains access to resources belonging to another user with similar access rights (e.g., one customer accessing another customer’s account). | Insecure Direct Object References (IDOR), session fixation, predictable resource identifiers. | |
Policy & Configuration Exploitation | Security Misconfiguration | Exploiting flaws in the configuration of access control policies, systems, or applications that leave unintended access paths open. | Unnecessary services left enabled, default security settings unchanged, overly permissive access rules, publicly exposed S3 buckets with sensitive data. |
Broken Access Control (General) | A broad category where restrictions on what authenticated users are allowed to do are not properly enforced, allowing them to bypass intended access limitations. | OWASP Top 1 category; includes many specific vectors like IDOR, missing function-level access control, etc. | |
Exploiting Trust Relationships | Abusing trusted connections between systems or accounts. If one system is compromised, it can be used to gain unauthorized access to other trusted systems. | Compromising a system that has privileged access to other internal systems, exploiting misconfigured cross-domain trusts. | |
Insider Threats | Legitimate users intentionally or unintentionally misuse their authorized access to exfiltrate data, cause damage, or enable external attacks. | Disgruntled employees, negligent users, compromised employee accounts. | |
Session Management Attacks | Inadequate Session Management | Weaknesses in how sessions are created, maintained, or destroyed, allowing attackers to impersonate users. | Predictable session IDs, sessions not expiring properly, session IDs exposed in URLs. |
Direct Access Control Mechanism Bypass | Insecure Direct Object References (IDOR) | An application exposes a direct reference to an internal implementation object (e.g., a file, directory, or database key) without proper access checks. | Manipulating URL parameters (e.g., ?user_id=123 to ?user_id=124 ) to access other users’ data. |
Missing Function-Level Access Control | Failure to verify permissions when a user tries to access a specific function or API endpoint on the server-side, even if the UI hides the option for that user. | Directly Browse to restricted URLs or crafting API requests for functions the user shouldn’t be able to access. | |
URL Manipulation/Forced Browse | Altering URL parameters or paths to attempt access to unauthorized pages, functionalities, or data. | Guessing URLs for admin panels, modifying query strings to bypass filters, accessing unlinked resources. | |
Path Traversal / Directory Traversal | Exploiting insufficient input validation to access files and directories stored outside the web root folder or intended directory. | Using ../ sequences in URLs or input fields to navigate the file system. |
Mistake #1: You don’t have a single source of truth
A common pitfall many developers encounter is the ad-hoc creation of new roles or permissions for nearly every distinct type of action or resource. This often leads to a proliferation of similar, yet slightly different, role definitions scattered throughout the codebase.
// ❌
export enum Permission {
CanViewProduct = "can_view_product",
CanEditProduct = "can_edit_product",
// ...
}
This approach is detrimental primarily because we are essentially creating a mirror image of an enumeration or dataset that often already exists or should exist centrally. This violates the “Don’t Repeat Yourself” (DRY) principle, leading to increased maintenance overhead. When a new action is added or an existing one modified, developers have to remember to update it in multiple places – the core logic and the disparate role definitions. This inevitably leads to inconsistencies, bugs, and a system that is hard to reason about.
In an RPC (Remote Procedure Call) style API, a more robust solution is to directly leverage the enumeration that defines all possible RPC calls. Instead of thinking of roles as their own distinct set of enums, we can consider “permissions” or “allowed actions” as directly corresponding to these API actions. This doesn’t mean you need an explicit handler function for each individual permission check; both REST and RPC style APIs have established patterns to address this efficiently.
// ✅
enum Action {
CompanyApiCreate = "companyapicreate",
CompanyApiUpdate = "companyapiupdate",
CompanyApiDelete = "companyapidelete",
CompanyApiRoleOwner = "companyapiroleowner",
CompanyApiRoleEditor = "companyapiroleeditor",
CompanyApiRoleViewer = "companyapiroleviewer",
CompanyApiRoleDelete = "companyapiroledelete",
ProductApiCreate = "productapicreate",
ProductApiUpdate = "productapiupdate",
ProductApiDelete = "productapidelete",
ProductApiRoleOwner = "productapiroleowner",
ProductApiRoleEditor = "productapiroleeditor",
ProductApiRoleViewer = "productapiroleviewer",
ProductApiRoleDelete = "productapiroledelete",
// ...
}
I often find it beneficial to include client/frontend-only actions within this central enums as well (which is why I don’t name it Operation
, as it encompasses more than just Queries and Mutations). To keep backend and frontend shared enums/types synchronized, tools like rsync
can be used. If a monorepo framework is a good fit for your project (and it often is), then solutions like NX or Turborepo can streamline this synchronization.
This centralized enum approach is particularly straightforward if your API design uses a single POST endpoint (alongside a corresponding OPTIONS endpoint for pre-flight requests) and uses switch statements or a similar dispatch mechanism to route requests to the correct handler based on an action parameter. If you’re not using this specific architecture, the RESTful solution described below offers an alternative.
In a REST style API, you can achieve a similar single source of truth. The auth middleware can use the request context, specifically the URL path, to determine the corresponding Action
enum variant.
enum Action {
CompanyApiCreate = "/api/company/create",
CompanyApiUpdate = "/api/company/update",
CompanyApiDelete = "/api/company/delete",
// ...
}
This RESTful approach is arguably less ideal than the RPC solution because it introduces a dependency: you must meticulously keep your defined API routes and the paths represented in your action enum synchronized. Any divergence can lead to incorrect permission evaluations.
Once this single source of truth for actions is established, we can define permission maps. These maps will be consumed by your auth middleware to determine if a given user or entity has the right to perform a requested action.
// src/types/...
type RoleMap = { [subject: string]: Action[] };
type RoleMutationMap = { [subject: string]: { [target: string]: Action[] } };
// src/helper/...
// what x role allows
const roleMap: RoleMap = {
// this action/role is attached to a company (shown later)
[Action.CompanyApiRoleOwner]: [
Action.CompanyApiUpdate,
Action.CompanyApiDelete,
// therefore since i own the company i can add products to it
Action.ProductApiCreate,
],
[Action.ProductApiRoleOwner]: [
Action.ProductApiUpdate,
Action.ProductApiDelete,
],
[Action.ProductApiRoleEditor]: [
Action.ProductApiUpdate,
],
[Action.ProductApiRoleViewer]: [],
// ...
};
// what x role can change about others roles
const roleMutationMap: RoleMutationMap = {
// ...
// given i (the user) own the product
[Action.ProductApiRoleOwner]: {
// i can perform the following actions on the other product owners
[Action.ProductApiRoleOwner]: [
// i can give them editing or viewing permissions instead
Action.ProductApiRoleEditor,
Action.ProductApiRoleViewer,
// or i can remove their permissions entirely
Action.ProductApiRoleDelete,
],
[Action.ProductApiRoleEditor]: [
// ...
],
[Action.ProductApiRoleViewer]: [
// ...
],
},
[Action.ProductApiRoleEditor]: {
// ...
},
[Action.ProductApiRoleViewer]: {
// ...
},
};
It’s crucial that role-based actions or specific permissions are persisted effectively. Typically, this involves a dedicated table related to your primary entity table. For example, if you have a product
table, you would likely need a product_role
table to store which users have what roles.
export const product_role = sqliteTable(
"product_role",
{
product_id: text().notNull(),
user_id: text().notNull(),
// this can even keep track of invites, rejections and blocks
action_enum: text().notNull(),
// include identity, versioning, timestamp rows...
},
(table) => [
primaryKey({
columns: [table.product_id, table.user_id],
name: "product_role_pk",
}),
// include foreign keys if u use those, see my article on foreign keys
]
);
Now that we have established a single source of truth for actions and have a strategy for persisting roles and permissions, how do we actually apply these in our auth logic consistently? This leads us to the next common mistake.
Mistake #2: You have multiple points of failure
Let’s begin by acknowledging a common pattern: many applications use helper functions for access control and authorization checks. While there’s nothing inherently wrong with the helper pattern itself, its widespread and uncoordinated application can lead to significant problems. The following conceptual examples, inspired by patterns seen in various projects, illustrate this:
// ❌
export default async function DashboardPage() {
const user = await getCurrentUser();
if (!user) redirect("/sign-in");
return (
<div>
<h1>Dashboard</h1>
</div>
);
}
// ❌
export const GET = async (
req: Request,
{ params }: { params: Promise<{ groupId: string; imageId: string }> }
) => {
const user = await getCurrentUser();
// ...
}
The core issue here is not the use of helpers, but the way they can lead to needless repetition and a scattered approach to authorization. When permission checks are sprinkled throughout various parts of the application (both frontend and backend) using standalone helper calls, you create multiple potential points of failure. If the authorization logic needs to change, you have to hunt down and update every instance, which is error-prone and inefficient.
To address this on the frontend, we can introduce a more centralized approach using the context pattern. Modern frontend frameworks like React have built-in primitives (React Context), which are excellent for managing global state like user permissions.
// ✅
// src/context/...
export type AppContextReturn = {
user?: User;
};
export const AppContext = React.createContext<AppContextReturn>();
type Props = {
children: React.ReactNode;
};
export const AppProvider = ({ children }: Props) => {
// all queries/mutations -> src/service/...
const userQuery = useQuery({
queryKey: ["..."],
queryFn: async () => {
// ...
},
});
const [user, setUser] = React.useState<User>();
React.useMemo(() => {
setUser(userQuery.data);
}, [userQuery.data]);
const value: AppContextReturn = {
user,
};
if (userQuery.isPending) {
return "Loading...";
}
if (userQuery.error) {
return "An error has occurred: " + userQuery.error.message;
}
return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
};
export const useApp = (): AppContextReturn => {
const context = useContext(AppContext);
if (context === undefined) {
throw new Error("useApp must be used within an AppProvider");
}
return context;
};
// src/...
const App = () => {
const app = useApp();
return (
<AppProvider>
{app.user ? <SignIn /> : <Dashboard />}
</AppProvider>
);
};
Similarly, backend and API frameworks also typically provide built-in context mechanisms, often associated with the request-response lifecycle. To fix the scattered backend checks, you can do something like the following:
// ✅
// src/types/...
export type HonoBindingsEnv = {
// ...
};
export type HonoBindings = HonoBindingsEnv & {
// ...
};
export type HonoVariables = {
user?: User;
// ...
};
export type HonoApp = { Bindings: HonoBindings; Variables: HonoVariables };
export type HonoContext = Context<HonoApp, string, {}>;
// src/middlware/...
const authMiddleware = createMiddleware<HonoApp>(async (c, next) => {
// 1. get session id cookie from request header
// 2. lookup the session + user in your db
// 3. determine if the session + user is valid, throw if not
// 4. determine allowed actionList and attach to user
c.set("user", user);
// 5. determine allowed actionList for targetted user/entity given the currently signed in user
// 6. determine if requested action is listed in targeted user/entity or in the current user itself
});
// src/index.ts
const app = new Hono<HonoApp>();
app.post(
"/...",
// ...
authMiddleware,
// ...
async (c, next) => {
// no permission checks required whatsoever
// ...
}
);
export default app;
It is key for your auth middleware and any services that need to understand permissions to share the same underlying helper to calculate this actionList
.
// target: src/middleware/auth... + src/service/entity/list...
// source: src/helpers/...
const listActionProduct = (
c: HonoContext,
product: Product
) => {
let actionList: Action[] = [];
// 1. determine if current user has a role for this product
if (!role) {
return actionList;
}
// 2. determine base actions
actionList = roleMap[role];
// 3. determine which actions to include/exclude depending on one or many product attributes
if (product.stock === 0) {
actionList = actionList.filter(
(action) =>
!actionListExcludeOutOfStock.includes(action)
);
}
// you can even use your integrations here to modify the allowed actions
// for example: determine if the user is a paid subscriber etc
return actionList;
};
// we would also create functions to determine the actions a user can perform on other users who have permissions to the same product (roleMutationMap)
This centralization ensures that permission evaluation is consistent and changes to the logic only need to be made in one place, drastically reducing the risk of inconsistencies and making the system easier to maintain and audit.
Mistake #3: You hardcode how functionality is hidden/disabled/allowed on the frontend
Once again, this prevalent issue stems from the problematic practice of repeating yourself unnecessarily, leading to a brittle and hard-to-maintain frontend. Imagine your application six months down the line, littered with numerous if (user.role === 'ADMIN')
or if (user.canEditArticle)
checks scattered across dozens or even hundreds of components. Maintaining, updating, or debugging such a system becomes a nightmare.
By using your “single point of failure” and “single source of truth” on the backend we return every user/entity with an actionList. This actionList is calculated using the same helper the auth middleware uses, therefore any action in the list is accurate to what is actually allowed. Keep in mind that users only have roles in relation to other users/entities, therefore a role is never attached to the current user object.
{
id: "d968b194-24bf-499d-8539-565eeab57a5a",
title: "Sauce",
price: 10,
actionList: [ "productapiedit", "productapidelete" ]
}
// ✅
type Props = {};
const Product = (props: Props) => {
// put in src/service/...
const productQuery = useQuery({
queryKey: ["..."],
queryFn: async () => {
// ...
},
});
// use reusable components from src/components/...
if (productQuery.isPending) {
return "Loading...";
}
if (productQuery.error) {
return "An error has occurred: " + productQuery.error.message;
}
return (
<div>
<h1>{productQuery.data.title}</h1>
<p>{productQuery.data.price}</p>
<div class="flex gap-2">
{productQuery.data.action_list.map((action) => (
// reference enum created in src/types/...
// to determine the enums corresponding name/label
<button>{action}</button>
))}
</div>
</div>
);
};
export default Product;
This way, the frontend UI elements (buttons, links, form fields) are dynamically rendered, enabled, or disabled based on this authoritative actionList
. If a permission changes on the backend, the frontend automatically reflects this change without requiring modifications to individual component logic. This keeps your frontend code cleaner, more maintainable, and ensures that the UI accurately represents the user’s actual capabilities as enforced by the backend.
Other mistakes
This article has focused on foundational issues, but the world of access control and auth is vast. In future posts, I plan to go into other common problem areas, such as the nuances and pitfalls of JWT (JSON Web Token) implementations, effective CSRF (Cross-Site Request Forgery) prevention strategies, proper CORS (Cross-Origin Resource Sharing) setup, a discussion on why rolling your own auth is oftentimes underrated, and how to effectively use third-party auth providers to achieve a robust “best of both worlds” approach.
Final thoughts
Implementing robust access control is not just a feature; it’s a fundamental pillar of application security. As we’ve seen, seemingly small oversights like scattered logic or duplicated sources of truth can quickly escalate into significant vulnerabilities, leaving applications exposed to unauthorized access and serious breaches.
The key takeaways are to strive for centralization in your authorization logic, establish a single source of truth for actions and permissions, and ensure that your frontend accurately reflects the decisions made by the backend. By moving away from repetitive, hardcoded checks and embracing patterns like backend-driven action lists and context-based permission propagation, you can build systems that are not only more secure but also significantly easier to maintain, scale, and reason about.
Take the time to critically evaluate your current access control mechanisms. Are you inadvertently creating multiple points of failure? Is your permission logic DRY, or is it scattered and duplicated? Addressing these questions proactively can save you from becoming another statistic in OWASP’s next report and, more importantly, protect your users and your organization.