This blog is based on Or Weis' talk "Product Permissions - Common Pitfalls and How Not to Fall For Them" at Open Security Summit.
Access control is a must in almost any application, yet most developers end up building and rebuilding it time and time again - forced to refactor with new customer, product, or security demands coming in.
Why? Usually, they make one or more of four crucial mistakes that prevent them from having a flexible access control layer that can be upgraded without having to rebuild it every time a new product demand comes in.
Before we dive into these, we first need to acknowledge that Permissions are hard, and they're becoming harder as the world moves into cloud-native ecosystems and microservices. Let’s try and understand why -
What makes permissions complex?
Moving from monoliths to distributed microservices
Back when applications were structured as monoliths, the decision-making process of who connects to what within an application could be baked into one place, usually by using a specific framework such as Spring, Django, or Python.
When working with distributed microservices, especially in a polyglot structure, these solutions are no longer applicable, so you end up having to sprinkle a bit of access control into every little microservice and component you're building. This creates an issue where we struggle to upgrade, add capabilities and monitor the code overall as it is replicated between different microservices.
Connecting to 3rd party services
It’s not just about the services you provide anymore: The ability to connect your application to 3rd party services (Like authentication, billing, analytics, machine learning agents, or databases) has become a crucial aspect of building any application - this requires us to manage access control for elements outside our own cloud.
New, complex permission models
The policies, rules, and models we want to enforce permissions with are also becoming more complex. Applications often start with an Admin / Non-Admin model and quickly move to increasingly complex models such as RBAC, ReBAC, and ABAC. Our expectations for applications and the different ways we can collaborate within them have never been higher, creating a need for ever-evolving complex policies.
Security and Compliance
If back in the day, doing SOC 2, ISO, or meeting GDPR or CCPA standards was out of the ordinary, today, these standards are common for basically any B2B (and often B2C) application.
These standards, along with HIPPA and PCI, all have lots of requirements related to having checks and balances for your application's access control.
We can significantly reduce the amount of hardship we need to go through to meet compliance standards by implementing good access control. Having a feature like auditing, combined with visibility into your access control level, which you can share with your auditors, security, and compliance, is expected from almost any solution being built - maybe not on day one, but probably quickly down the road.
If that's not enough, there's a lot of security friction and vulnerabilities surfacing from challenges in building access control. It's no surprise that broken access control is the top A1 item on OWASP’s list of vulnerability sources, with the highest occurrence across all the surveys.
Permissions are not only a problem in how we manage and build things, but it is also creating security issues in our production, which we may face as security incidents.
Now that we understand the depth of the problem, let's jump into the common anti-patterns that people often end up implementing into the access control that they're building, creating security vulnerabilities.
1. Mixing ‘Auths’ - Authentication VS Authorization
Mixing up authentication and authorization is probably the most common pitfall out there. Despite both belonging to the IAM space, the two are very different. In short - the IAM space consists of three parts: Identity Management (IM), Authentication (AuthN), and Authorization (AuthZ).
Identity management solutions like OKTA and Azure Active Directory are used on the organization side, defining different organizational identities and their relationships.
Authentication is done on the product side, where you verify identities before allowing them into the product (Log in).
Authorization is the layer that lets us enforce and check permissions within the product.
These three steps trickle down into one another, but we must understand their differences.
One source of confusion between identity management, authentication, and authorization stems from their use of Roles. The concept of Roles exists through the IAM space, but the meaning varies in each part.
Identity Management Roles indicate a role within an organization (Like “Head of Marketing” or “Member of Security Team”). These roles are drastically different from those used on the application level (Like “Admin,” “Reader,” or “Viewer”). Translating IM organizational roles into application-level Authorization roles is not always a straightforward process, but it is an issue that clearly needs to be addressed.
The result of this translation (For example - Deciding that our Head of marketing, who is part of the marketing team, should have an editor role for the CMS) should be saved as part of the JSON web token (JWT) produced by the authentication layer.
What else should JWTs be used for? Glad you asked.
Basically, the translation of the IM role into an authorization layer role (Or - the translation from an organizational role into an application level role) should be the only thing included in the JWTs.
Developers often tend to overuse JWTs, sometimes going as far as storing all the routes that a user should access within them. That is a bad idea for several reasons:
Mixing the authentication and authorization layers messes up our code.
Changing roles requires the user to log out and log in again, which can hinder the user experience and overall application performance.
As the JWT is sent for every request with a RESTful API, GraphQL, or any other HTTP-based solution, creating a bloated JWT would significantly slow down the application’s performance.
There is a limit to how much data can be stored within a JWT. If we keep adding more rules, we will eventually run out of storage space.
The best way to avoid this is to have the JWT only include the claims and scopes for the user's identity and their relationship within the organization and keep all other authorization-related information in a separate layer within the application.
Speaking of creating a separate layer for authorization, the next common mistake devs make is mixing up application logic with authorization logic.
2. Mixing up App-logic and Authorization
When building an application, we have the logic of what the application is supposed to do, and we have the logic for who is allowed to perform which actions within it.
The combination of these two logic sets could easily result in us having spaghetti code composed of a mix of unrelated elements. If we will need to upgrade the application or the authorization layer (Which is rather likely to happen at some point), we'll end up having to cherry-pick different elements of code, trying to figure out which are application related and which are authorization related.
When these two logic sets are combined, they inevitably grow organically and become increasingly cumbersome, turning any future effort of separating, editing, updating, or upgrading them into a nightmare.
There’s also an issue of performance here - Let’s say our application checks a user's payment status to approve or deny access to certain features. Checking this every time as part of the application flow could significantly hinder the application’s performance.
A much better way to approach this issue is to decouple our policy from our code. This way, the authorization layer can run in the background and monitor the user’s payment status. Whenever we want to check that status, it will already be available in the authorization layer, eliminating the need to fetch it every time.
3. Mixing up your Access control layers
Every application requires multiple layers of access control -
Physical access control, like having locks on our doors and windows.
Network level access control, like firewalls, VPNs, and zero trust networks.
Infrastructure level access control, like limitations on which services can talk to each other.
And lastly, application level access control.
Each of these layers has different demands, requirements, policies, and models. Ideally, we would have a unified interface/back-office where we can view, manage and audit all these different layers. The ability to manage them as code would be even better, as it allows us to manage and run tests on them as part of our source control.
The main mistake we often see developers making in this aspect is combining their application-level access control with a tool that was built to manage infrastructure access control, like AWS IAM.
Initially, this might look like a great idea - AWS IAM is a very powerful tool that can map many things into objects, which is why engineers use it to map their application-level access control. So why is this not a good idea? Let's look at a specific example.
We often encounter engineers mapping their application-level access control using SS3 buckets in AWS. Because their data is stored in a bucket anyway, they feel they might as well use the access control for the bucket for the application itself. Although this may sound great on the surface, it's easy to see when things start to go wrong -
The moment you’ll want to move to a different cloud or even a different storage layer within the same cloud, there’s a good chance you won't want to use the buckets anymore - you’ll want to use RDS, Redshift, or Snowflake. Just because the requirements for your application have changed, you will have to completely refactor your access control because it was coupled into that specific cloud component infrastructure.
It can also be a major problem to rely too much on how things are built for these specific components. A lot of people rely on the way you configure the API gateway to do enforcements for your application. Sadly, AWS encourages you to put routes inside the JWT - a practice devs end up adopting as something they can take into production in scale and end up regretting in the long run.
Ultimately, it's important to remember that different access layers have different needs. If we ignore these needs, we might regret it later and end up having to refactor large parts of our applications.
4. Thinking that you can solve it once and for all
The last major mistake developers tend to make when thinking about permission building is more of a conceptual one - Thinking they can solve it once and for all.
Many young companies fall for this misconception and end up rebuilding their access control over and over again instead of developing crucial new features in the application itself. The worst thing about that is that every time they do it, they think this time will be the last. In reality - if you follow even just some of the anti-patterns we described here, there's a good chance that every time a new requirement comes in from a customer, partner, security, or compliance, you just might have to throw out everything you've built and start from scratch.
The only way to avoid this scenario is to plan for it. Building an ever-growing, ever-developing application, you have to assume that you will have to evolve your authorization layer to support more policies and complex roles. You have to assume you will move from RBAC to ABAC or other even more complicated models and provide interfaces on top of them.
This doesn’t mean you should try and build a perfect, future-proof permission management system from day one - especially if you are working in a startup company. You’ll just never finish. Instead, focus on setting the right groundwork - if you plan ahead and avoid the mistakes we discussed in this post, you will be able to upgrade your permission layer with much more flexibility - instead of having to rebuild it from scratch every three to six months.
Most companies go through the same steps - they initially build good schemas for their data layers and authorizations, but gradually, as they move forward, these schemas stop working, and they start facing performance issues. The only way to avoid this is to decouple your authorization layer from your application’s logic and be ready to update it gradually as demands come in.
Let's sum everything up -
Whether it's the move to distributed microservices from monoliths, the requirement to integrate 3rd party services into your application, the necessity of using complex permission models, or the rise of security and compliance requirements, permissions have become more complex than ever.
All of these changes require us to adopt new best practices and avoid common mistakes developers make when thinking about authorization:
Mixing Authentication and Authorization, especially when it comes to the somewhat confusing translation of organizational roles into application level ones and the correct usage of JWTs, mixing up application logic with authorization logic, using tools designed for infrastructure access control (like AWS IAM) to manage application level access control, and thinking that we can solve our authorization problems once and for all on day one.
Most importantly, it's important to understand that developing a good authorization layer in a constantly changing application requires planning ahead and building a flexible solution that can be upgraded without having to rebuild it every time a new product demand comes in.
Building authorization? Got questions? Come talk to hundreds of developers working on solving their IAM challenges in our Slack community!