Azure Single Sign-On Case Study #1: Secure architecture for SPA & API
A case study of an insecure Single Sign-On architecture followed by a comprehensive guide on how to do it the right way.
During a recent cloud configuration assessment, I came across an insecure configuration: a single App Registration instance in Azure was being shared among multiple applications. While I was sure this setup was insecure, I kept wondering, what was the actual risk? When does a misconfiguration become a vulnerability?
It also got me thinking — most documentation and courses tend to focus on the simplest things or textbook configuration options, often meant just for testing purposes. However, real-world architecture rarely follows straightforward paths. We need to be aware of possible security solutions to implement them in our complex use cases.
In this article, I will:
- Explain the insecure configuration with a shared App Registration,
- Show the actual consequences of such a configuration,
- Discuss the difference between app roles and scopes, focusing on their usage in corporate environment,
- Prepare an example of a complex architecture consisting of multiple APIs and web applications.
A tale of a single App Registration
Let’s consider the following scenario:
- There are multiple single-page applications.
- They call multiple APIs — some SPAs have dedicated APIs, but there are also cases where one API is used by several SPAs.
- We want to implement authorization so that only users from selected groups can use specific functionalities according to their privileges.
The cloud architecture in place implemented the following approach:
Basically, all SPAs used the same App Registration instance, with multiple redirect URIs configured:
The configuration appears questionable, but what were the consequences?
Shared App Registration instance: impact analysis
Microsoft documentation clearly states the rules of the App Registration separation:
Avoid using the same app registration for multiple apps. Separating app registrations helps you to enable least privileged access and reduce impact during a breach.
– Use separate app registrations for apps that sign in users and apps that expose data and operations via API (unless tightly coupled). This approach allows permissions for a higher privileged API, such as Microsoft Graph and credentials (like secrets and certificates), at a distance from apps that sign in and interact with users.
– Use separate app registrations for web apps and APIs. This approach helps ensure that, if the web API has a higher set of permissions, then the client app doesn’t inherit them.
However, one can still not be sure what level of separation is optimal for secure architecture, especially when it comes to more complex scenarios. Also, what happens if we decide to take some shortcuts and consider all our applications tightly coupled? Would it really affect the overall security of our company?
So, let’s discuss the real-life impact of the insecure architecture used in this case study.
APIs using ID tokens instead of access tokens
Due to improper separation of SPAs and APIs, the difference between ID tokens and access tokens is not clear anymore. Since, in our case, all SPAs and APIs are identified as the same resource, it seems they can all exchange an ID token freely, forgetting about access tokens entirely. In the long run, it is a slippery slope — but it only makes sense if we know what the correct architecture would look like.
If you want to find out more about the difference between access tokens and ID tokens, check out these resources: ID Token and Access Token: What Is the Difference? (auth0.com), ID Tokens vs Access Tokens (oauth.net).
APIs accepting the same JWT — even those for another SPAs
The most important ID token claims are:
- Audience (aud): The Client ID of the App Registration,
- Issuer (iss): The URL address of the Azure tenant,
- Subject (sub): The identifier of the user that has logged in.
In the described architecture, the fact that these applications are separate entities is nowhere to be seen — a JWT will state that a user (sub) logged in using our Azure tenant (iss) to the shared App Registration instance (aud). All applications have the same Client ID, which is similar to using the same key to open many doors. As a result, with a single authentication attempt, it is possible to gain access to any application, even if we do not need it, which is not compliant with the principle of least privilege.
On the other hand, if we decide to use access tokens, it is also impossible to do it correctly in an improperly separated architecture.
By default, if no custom scopes are requested, the returned access token will be issued for Graph API. This can be identified by its designated audience (00000003–0000–0000-c000–000000000000). It is definitely not suitable for our APIs as they are not the intended recipient.
To issue an access token for my APIs, I defined a custom scope for each of them (App Registration > Expose an API):
However, since we only use one App Registration instance, there are no restrictions regarding what APIs we can request access to. It might seem that the vulnerable Marketing App only requests a scope for Marketing API, but we can manually insert additional scopes at the HTTP request level.
We will discuss scopes in more detail in the subsequent sections.
Compromised effectiveness of sign-in logs
Another issue worth considering is that when we examine the authentication logs (Enterprise Application > Sign-in logs), all we see is the App Registration associated with the authentication attempt but not the redirect URI. As a result, in case of a breach, identifying which application the attacker has authenticated to becomes a perplexing puzzle.
Legacy configuration for SPAs supporting older app
I have also noticed that the shared App Registration supported issuing access tokens and ID tokens via the implicit grant, which will be deprecated in OAuth 2.1 and is discouraged by Microsoft.
One of the SPAs was outdated and relied on legacy libraries lacking support for PKCE (an OAuth extension that will be discussed later), which explains the situation.
Consequently, this led to a reduction in security measures across all applications.
Access control checked in the app
To pass information about the user’s privileges to the client application, the App Registration was configured to include all the user’s security groups and directory roles within the tokens.
If the applications were correctly separated, we could enforce access restrictions at the Azure authentication level. Essentially, this would prevent login unless the user belonged to the appropriate group. Therefore, an unauthorized user would not receive a token that could be utilized for API access. Implementing this approach would add an extra layer of security, especially if access control within any of the applications had any imperfections. Additionally, we could only pass on information about roles that are essential for our application.
Azure App Registration separation of SPAs and APIs
Now, we will try another approach that uses separate App Registration instances for our applications and APIs.
The use case is as follows:
- The Marketing App only calls the Marketing API. All users are served the same content. Only the Marketing team can access the application.
- The HR App only calls the HR API. The users can access different functionalities depending on their role, which is based on their position (All Users or HR Department).
- The Accounting App calls the Accounting API in full scope, but it also calls the HR API within a limited scope (access to employees’ timesheets). In that case, the role of the user does not matter. Only the Accounting team can access the application.
Secure Azure configuration for Single-Page Application
Redirect URI
Each application only has their own redirect URI configured. Refrain from using wildcards.
Authorization code flow with PKCE
Usually, an application implementing the authorization code flow uses a Client ID and Client Secret. These are needed when exchanging a code for a token, proving that the party making the request is the same one that requested the authorization code before. However, single-page applications run entirely on the client side, meaning they cannot securely store a Client Secret. For this reason, the PKCE extension has been introduced. It works by using only the Client ID with a code challenge, which is generated randomly in a browser for each authorization attempt. This allows SPAs to securely implement the authorization code flow, even without the ability to store a Client Secret.
If it is absolutely necessary, enable an implicit grant and hybrid flows for legacy applications. Otherwise, keep away from them and stick with PKCE.
Centralized role management using app roles
Configure app roles you would like to use in your application for access control — like with the HR App from the diagram. If your app has different permissions for different user groups, you can configure it in App registration > App roles:
Then, we can match the existing users and groups to the app roles in the corresponding service principal’s settings (Enterprise application > Users and groups):
By default, users who are not assigned any roles and do not belong to the authorized groups will be able to log in with the Default Access role assigned to them. However, it is also possible to restrict access to unauthorized users entirely by enabling the Assignment required option in Enterprise Application’s properties. When this option is enabled, unauthorized users will be prevented from logging in:
App roles provide a centralized mechanism for managing user permissions, which proves exceptionally convenient in large organizations during both onboarding and offboarding processes.
There are some differences between assigning app roles to SPAs and APIs.
- If the user logs in to the Accounting App and requests access to the Accounting API, the app roles for the Accounting App will be included in the ID token, whereas the app roles for the Accounting API will be included in the access token.
- The roles will be consumed by the API, meaning that we only need them in the access token. However, as you can see, I have also assigned app roles for the SPAs, but I did it to restrict access for specific users. This means tokens will not be issued if these users are not authorized.
Let’s consider the following examples of users who would like to log in to the Accounting App and request access to the AccountingAPI.FullScope:
As you can see, restricting access to the SPA allowed us to introduce another level of protection. Otherwise, all users would be able to receive tokens for the API (like Bob), just without any roles, opening an opportunity for exploiting access control issues.
Secure Azure configuration for API
Defining scopes
An App Registration representing an API will not have any redirect URIs or client credentials configured. Instead, it defines available scopes (App registration > Expose an API):
Now, it is possible to authorize the HR App and Accounting App to specific scopes. The goal is to grant the least possible privileges.
App roles vs. app scopes
Now, let’s focus on the difference between app roles (which we have configured for both the SPAs and the APIs) and scopes (which we have configured only for the APIs). In Azure, both are used to implement authorization in your applications and APIs, but each of them is used in different scenarios.
- App roles define roles within an application and assign them to users or groups. The App Registration defines the available roles, whereas the Enterprise Application instance allows you to assign users from your Entra ID to specific roles. As mentioned, the role will be included in the relevant token — roles configured for an API will be present in the access token, and roles configured for an SPA will be included in the ID token. The API can use this information to determine the user’s permissions.
- API scopes, on the other hand, are meant to be used for permission delegation and can be used to ensure that the API can only be called by authorized applications. In the App Registration panel, we can create an allowlist of applications that can request specific scopes.
Your API should always verify the received token to make sure it includes the required API scopes, and the user has the necessary app role.
I will now consider another example of improper App Registration separation that will help you better understand the difference between app roles and scopes. This time, the API App Registration is separated from the SPA, but the frontend for all users and the frontend for the HR Department (which has admin access) share one instance of the App Registration. Once again, since this App Registration will handle authentication of both All Users and the HR Department, it is not possible to restrict login access.
If you enter the HR App for standard users, it will request just the StandardAccess scope. The HR Admin App, on the other hand, will request the AdminAccess scope.
The problem is that Azure scopes do not work this way. Behind the scenes, the App Registration that manages both frontend apps has to be authorized to request both API scopes. It is not possible to restrict requesting API scopes to specific users.
In the above example, despite the frontend requesting only the StandardAccess scope, any user could potentially escalate their privileges by additionally requesting the AdminAccess scope.
However, there’s a solution: by creating distinct App Registration instances for both the HR App and HR Admin App, we can configure them correctly. The separation ensures that each application will stick to its intended access levels, mitigating the risk of unauthorized privilege escalation.
This time, the HR Admin App can request the AdminAccess scope, and Azure will only issue tokens for the members of the HR group. On the contrary, the HR App can request the StandardAccess scope. However, such configuration will not be convenient if we do not want to separate the admin panel from the rest of the application, especially if there are more roles, which leads us back to our initial configuration, which has app roles configured for the API.
It should be noted that different Identity Providers, such as Okta, might allow you to restrict access to scopes to specific user groups or provide other access policy configuration options. It is important to consider these differences when implementing your solution, especially if you use some kind of hybrid identity architecture.
SPA calling multiple APIs
If we have a case when one SPA needs to call multiple APIs, they do not have to accept the same access token. If they are separate entities architecture-wise, we should treat them as such in Azure. Each API should get its own App Registration instance and scopes. It might not seem important for a small infrastructure, but as it gets bigger, it might lead to granting excessive privileges to specific clients.
You can acquire access tokens for multiple resources using one authorization request. To do so, you can use a refresh token to request an access token for each API.
Consent prompt in a corporate setting
The last thing I would like to discuss in this article is a consent prompt.
A consent prompt allows the user to make an informed decision to grant or deny permissions to a third-party application, allowing it to access their data or perform actions on their behalf.
However, in a corporate setting, the reality is a bit different. As an Identity and Access Management Administrator, you know what permissions applications should be granted in your company. Also, you usually do not want users to be able to consent to untrusted applications on their own — i.e., log in with their corporate Microsoft account to a third-party app along with giving it access to all files and emails.
If possible, we recommend not allowing user consent. As a result, all permissions will be managed by administrators, and the consent prompts for users will be suppressed. Another option is to allow user consent for apps from verified publishers but only for selected “low impact” permissions.
The table below will help you determine in which situations a consent prompt will be shown:
Rethink your Azure Single Sign-On architecture
IT infrastructures are not built in a day. They are the result of many years of development, growth, and changes. Migration to the cloud often presents an opportunity for a fresh start, but the journey may lead us to unexpected destinations or reveal unforeseen shortcomings and opportunities for improvement as we progress. Moreover, the technology, including cloud solutions, changes constantly, introducing more and more security features.
For these reasons, it is important to take a step back and rethink our security solutions sometimes — including authentication schemes and Single Sign-On. The architectural design that worked fine for three applications may not necessarily scale well for thirty. Sometimes, potential modifications may appear insignificant, but a series of minor flaws can collectively lead to a significant issue.
Microsoft documentation allows for a quick and easy set-up of a working environment, primarily serving as templates or examples, which may not always align with production requirements. This case study, too, outlines a specific infrastructure — my goal was to illuminate a path rather than dictate a precise route.
Take a close look at your infrastructure. Is everything in its place, or perhaps some strategies have become obsolete? Does it only require a bit of polishing, or is a full-scale renovation in order? And remember, attempting to hide flawed solutions should not be an option — after all, sweeping under the rug never works long-term.