deposit boxes broken open, signifying insecure design
Photo by Jan Antonin Kolar on Unsplash

OAuth2 Scopes are NOT Permissions

We’ve talked to countless developers about how they’ve built and evolved their authorization systems over time. One common regret that keeps coming up involves getting burned by using OAuth2 scopes in a JWT as the sole basis for making authorization decisions.

OAuth2 scopes were never intended to be an authorization mechanism, and indeed are a really bad idea when used as a substitute for a real authorization architecture. So how has this anti-pattern emerged?

Starting with authentication

When developers build a new application, one of the first things they need to implement is a login system. Thanks to standards like OAuth2 and OpenID Connect (OIDC), and services like Auth0, they can just delegate the process of authentication to an external Identity Provider such as Google, Facebook, or GitHub. Then comes authorization - determining what a user can actually do.

The temptation of OAuth2 scopes

The very first authorization pattern developers implement involves differentiating “normal users” and “admins”. It’s very easy to create an OAuth2 scope to represent the “admin” permission. When a user that is determined to be an admin logs in, developers rely on the authentication system to place this admin scope into the JSON Web Token (JWT) that is minted for that user. Every call to a protected resource checks the JWT for this “admin” scope, and life is good.

The real world

Except, life is rarely that simple. Any serious application quickly runs into four problems.

Scope explosion

Applications grow to have many types of resources, and each of these resources (documents, reports, projects, repositories) support a few different operations (create, read, update, delete, list). A fine-grained permission system often creates a cartesian product of these resource/operation tuples, resulting in dozens (or hundreds) of scopes. Injecting all of these scopes into a JWT isn’t possible, since the HTTP authorization header will exceed size limits.

Stale permissions

Since the JWT is minted at login time and its lifetime is typically 1-24 hours, an application that relies on scopes encoded in the JWT instead of real-time permission checks runs the very serious risk of making authorization decisions based on an outdated snapshot of the user’s security attributes. Tying authorization to the lifetime of a token isn’t exactly “secure by design” – when an administrator changes the attributes or roles for a user, they expect these changes to affect that user’s permissions in near real-time. Having to wait for a token to expire before the new permissions are in effect is a security anti-pattern, and is dangerous in the case of a known breach. And creating a revocation system for JWTs is difficult, and beyond the scope of most systems that mint them.

No resource context

OAuth2 scopes can designate a general permission like read:document, but often applications have to make authorization decisions in the context of a specific resource (Alice has the read:document permission for the “company strategy” document, but not for the “Bob’s performance review” document). Any real-world scenario (for example, implementing access control lists on specific resources) requires a real authorization system.

Short-lived tokens lead to performance issues

The only practical solution to avoiding the security pitfalls of long-lived JWTs is to rely on very short token lifetimes. But the reason for using JWTs for authorization to begin with is that they don’t require checking back with a server. If JWTs have to be renewed every minute, they are the worst of both worlds: they always represent stale state (even if it’s just by a minute), and the constant need to reissue them puts a much higher operational load on the login system, leading to performance issues, since authentications using the OAuth2 protocol are expensive. It simply doesn’t make sense to use an authentication system for authorization purposes, since authorization calls happen 100x more times during a user session than authentication.

The right way to build authorization

Authorization should always be done in real-time - just before your code wants to access a sensitive resource. The authorization component or service should use the current attributes of the user, as well as the specific resource context, as inputs into the decision engine as it evaluates the authorization policy for the operation to be performed. And that decision engine should be deployed in the same subnet as your application (ideally as a sidecar container in the same pod), so that it can operate at 100% availability and at millisecond-scale latency.

Others agree...

Of course, we’re not the first to make this observation. As Vittorio Bertocci, Principal Architect at Auth0, eloquently describes in his blog post, OAuth2 scopes were never intended to be an authorization mechanism: they were invented so that an Identity Provider could allow users to delegate a subset of their permissions and data to applications.

We agree with Vittorio. Using OAuth2 scopes as a substitute for a real authorization system is Considered Harmful. A better approach is to think about authorization as a distinct operation that is downstream from authentication, but is equally critical to design and implement correctly.

Embracing a standard authorization system is a great way to get there quickly.