Acquiring Tokens For Non-Graph APIs In Azure Functions
I had a customer conversation earlier last week where an interesting scenario popped up - they were using Entra ID to protect their API hosted in Azure Functions, and wanted to make sure that they can use the access token for other Azure API access. The authentication they used was what’s known as Easy Auth.
Easy Auth is great because it’s basically the implementation of “flip the switch and be on your way” to protect API endpoints.

Right after enabling this capability, navigating to the function from a web browser will prompt users to sign in:

This works really well, and you can even obtain tokens in a documented way! Say I deploy a PowerShell app. Inside that app I can read an access token like this:
using namespace System.Net
param($Request, $TriggerMetadata)
Write-Host "PowerShell HTTP trigger function processed a request for url '$($Request.Url)'"
Write-Host "All request headers:"
$Request.Headers.GetEnumerator() | ForEach-Object { Write-Host " $($_.Key): $($_.Value)" }
$userPrincipalName = $Request.Headers['X-MS-CLIENT-PRINCIPAL-NAME']
$userId = $Request.Headers['X-MS-CLIENT-PRINCIPAL-ID']
$accessToken = $Request.Headers['X-MS-TOKEN-AAD-ACCESS-TOKEN']
But say I now want to use that token to request data from Azure through Azure Resource Manager (ARM). That’s when I will encounter this small wrinkle that is the token audience. If we take the JSON Web Token (JWT) that we get out-of-the-box and decode it, our token will resemble something like this:
{
"typ": "JWT",
"nonce": "c0MPGlpE1bE1xEKd6tObWX2p4QN9BMEcRYN4W-SkB9o",
"alg": "RS256",
"x5t": "JDNa_4i4r7FgigL3sHIlI3xV-IU",
"kid": "JDNa_4i4r7FgigL3sHIlI3xV-IU"
}.{
"aud": "00000003-0000-0000-c000-000000000000",
"iss": "https://sts.windows.net/b811a652-39e6-4a0c-b563-4279a1dd5012/",
"iat": 1742344168,
"nbf": 1742344168,
"exp": 1742349059,
"acct": 0,
"acr": "1",
"acrs": [
"p1"
],
"aio": "SOME_VALUE",
"altsecid": "SOME_VALUE",
"amr": [
"pwd",
"mfa"
],
"app_displayname": "SOME_VALUE",
"appid": "SOME_VALUE",
"appidacr": "1",
"email": "SOME_VALUE",
"family_name": "SOME_VALUE",
"given_name": "SOME_VALUE",
"idp": "SOME_VALUE",
"idtyp": "SOME_VALUE",
"ipaddr": "SOME_VALUE",
"name": "SOME_VALUE",
"oid": "SOME_VALUE",
"platf": "3",
"puid": "SOME_VALUE",
"rh": "SOME_VALUE",
"scp": "email openid profile",
"sid": "SOME_VALUE",
"signin_state": [
"kmsi"
],
"sub": "SOME_VALUE",
"tenant_region_scope": "NA",
"tid": "b811a652-39e6-4a0c-b563-4279a1dd5012",
"unique_name": "SOME_VALUE",
"uti": "SOME_VALUE",
"ver": "1.0",
"wids": [
"SOME_VALUE",
"SOME_VALUE"
],
"xms_ftd": "SOME_VALUE",
"xms_idrel": "1 30",
"xms_st": {
"sub": "SOME_VALUE"
},
"xms_tcdt": 1673308801
}.[Signature]
The audience here, represented by the aud
claim (fancy way of saying “property”), is a way to specify who the intended recipient of the token is. Think of it like addressing a letter with a specific recipient’s name. The audience value in a JWT tells the service or application that receives the token who it was meant for. If the audience doesn’t match what the service expects, the token might be considered invalid and not be accepted.
In our case, the audience is 00000003-0000-0000-c000-000000000000
, which is Microsoft Graph. This means that the token is meant for the Microsoft Graph API, and if we re-use it in other contexts, it will not be really that useful. If we try and use this token for an Azure API, we’ll get an error. For example, here is a test script PowerShell that I run inside a function to demo this scenario:
using namespace System.Net
param($Request, $TriggerMetadata)
Write-Host "PowerShell HTTP trigger function processed a request for url '$($Request.Url)'"
Write-Host "All request headers:"
$Request.Headers.GetEnumerator() | ForEach-Object { Write-Host " $($_.Key): $($_.Value)" }
$userPrincipalName = $Request.Headers['X-MS-CLIENT-PRINCIPAL-NAME']
$userId = $Request.Headers['X-MS-CLIENT-PRINCIPAL-ID']
$accessToken = $Request.Headers['X-MS-TOKEN-AAD-ACCESS-TOKEN']
$clientPrincipal = $null
$clientPrincipalHeader = $Request.Headers['X-MS-CLIENT-PRINCIPAL']
if ($clientPrincipalHeader) {
$decodedHeader = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($clientPrincipalHeader))
$clientPrincipal = $decodedHeader | ConvertFrom-Json
}
# Initialize response object
$responseObject = @{
isAuthenticated = $false
userPrincipal = $null
accessToken = $null
}
if ($userPrincipalName) {
$responseObject.isAuthenticated = $true
$responseObject.userPrincipal = @{
name = $userPrincipalName
id = $userId
}
if ($accessToken) {
$responseObject.accessToken = $accessToken
}
}
if ($accessToken) {
try {
Write-Host "Attempting to list Azure resource groups using REST API"
# Create headers with the access token
$headers = @{
'Authorization' = "Bearer $accessToken"
'Content-Type' = 'application/json'
}
# Get all subscriptions user has access to
$subscriptionsUrl = "https://management.azure.com/subscriptions?api-version=2020-01-01"
$subscriptionsResponse = Invoke-RestMethod -Uri $subscriptionsUrl -Headers $headers -Method Get
# Initialize an array to store resource groups
$allResourceGroups = @()
# For each subscription, get resource groups
foreach ($subscription in $subscriptionsResponse.value) {
$subscriptionId = $subscription.subscriptionId
$subscriptionName = $subscription.displayName
Write-Host "Getting resource groups for subscription: $subscriptionName ($subscriptionId)"
$resourceGroupsUrl = "https://management.azure.com/subscriptions/$subscriptionId/resourcegroups?api-version=2021-04-01"
$resourceGroupsResponse = Invoke-RestMethod -Uri $resourceGroupsUrl -Headers $headers -Method Get
# Add subscription info to each resource group
foreach ($rg in $resourceGroupsResponse.value) {
$rg | Add-Member -NotePropertyName 'subscriptionId' -NotePropertyValue $subscriptionId
$rg | Add-Member -NotePropertyName 'subscriptionName' -NotePropertyValue $subscriptionName
$allResourceGroups += $rg
}
}
# Add resource groups to the response
$responseObject.resourceGroups = $allResourceGroups
Write-Host "Found $($allResourceGroups.Count) resource groups across all subscriptions"
} catch {
Write-Host "Error retrieving Azure resource groups: $_"
$responseObject.error = "Failed to retrieve resource groups: $($_.Exception.Message)"
}
}
$body = $responseObject | ConvertTo-Json -Depth 10
Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{
StatusCode = [HttpStatusCode]::OK
Body = $body
})
It might be intimidating to look at this wall of code, but all it does is produce a JSON response that contains all the resources that I have in a given Azure subscription.

Stop, stop, STOP. Are you really also printing the access token in the response above? Isn’t that really bad? Like, really, really bad?
Indeed it is! This script is only used for diagnostics. You should never print tokens into logs, API outputs, or any other customer-facing artifact that is easily visible or can lead to compromise. Because I am using a test account, on a test subscription, with a test resource, this is a-OK, but you should never do this in production (or even staging).

But back to our PowerShell experiment - if I run the code as-is inside an Azure Functions app deployment, I will hit a wall:
Failed to retrieve resource groups:
Response status code does not indicate success: 401 (Unauthorized).
Uh oh. So, we need to adjust our token configuration a bit to make sure that the audience is properly reflected (along with the scopes). But how, when the UI doesn’t really offer this capability?
For that, we will need to use the Azure REST API. Well, maybe we can even go as far as use Azure CLI for it to make things easier. Previously, you could use the Azure Resource Explorer (https://resources.azure.com
), but that’s going away, so we will use this as one last reference:

What we need to modify is under:
subscriptions
- yourSubscriptionId
- resourceGroups
- yourResourceGroup
- providers
- Microsoft.Web
- sites
- yourFunctionId
- config
- authSettingsV2
In there, we need to modify the login
property to also include a new property - loginParameters
:
"loginParameters": [
"scope=openid email profile https://management.azure.com/user_impersonation"
]
Using standard OIDC openid
, email
, and profile
scopes will allow us to get basic user information. https://management.azure.com/user_impersonation
will allow us to access Azure resources with the token. Just setting this scope will automatically ensure that the right audience is used for the token (the actual scope reflected in the token will be user_impersonation
).
To perform the action above with Azure CLI, we will first need to compose a JSON file that is reflecting of the authSettingsV2
. It looks like this:
{
"id": "/subscriptions/YOUR_SUBSCRIPTION/resourceGroups/YOUR_RG/providers/Microsoft.Web/sites/YOUR_FUNCTION_NAME/config/authsettingsV2",
"name": "authsettingsV2",
"type": "Microsoft.Web/sites/config",
"location": "West US 3",
"tags": {
"hidden-link: /app-insights-resource-id": "/subscriptions/YOUR_SUBSCRIPTION/resourceGroups/YOUR_RG/providers/Microsoft.Insights/components/YOUR_FUNCTION_NAME"
},
"properties": {
"platform": {
"enabled": true,
"runtimeVersion": "~1"
},
"globalValidation": {
"requireAuthentication": true,
"unauthenticatedClientAction": "RedirectToLoginPage",
"redirectToProvider": "azureactivedirectory"
},
"identityProviders": {
"azureActiveDirectory": {
"enabled": true,
"registration": {
"openIdIssuer": "https://sts.windows.net/YOUR_TENANT_ID/v2.0",
"clientId": "YOUR_CLIENT_ID",
"clientSecretSettingName": "MICROSOFT_PROVIDER_AUTHENTICATION_SECRET"
},
"login": {
"loginParameters": [
"scope=openid email profile https://management.azure.com/user_impersonation"
],
"disableWWWAuthenticate": false
},
"validation": {
"jwtClaimChecks": {},
"allowedAudiences": [
"api://YOUR_CLIENT_ID"
],
"defaultAuthorizationPolicy": {
"allowedPrincipals": {},
"allowedApplications": [
"YOUR_CLIENT_ID"
]
}
},
"isAutoProvisioned": true
},
"facebook": {
"enabled": true,
"registration": {},
"login": {}
},
"gitHub": {
"enabled": true,
"registration": {},
"login": {}
},
"google": {
"enabled": true,
"registration": {},
"login": {},
"validation": {}
},
"twitter": {
"enabled": true,
"registration": {}
},
"legacyMicrosoftAccount": {
"enabled": true,
"registration": {},
"login": {},
"validation": {}
},
"apple": {
"enabled": true,
"registration": {},
"login": {}
}
},
"login": {
"routes": {},
"tokenStore": {
"enabled": true,
"tokenRefreshExtensionHours": 72,
"fileSystem": {},
"azureBlobStorage": {}
},
"preserveUrlFragmentsForLogins": false,
"cookieExpiration": {
"convention": "FixedTime",
"timeToExpiration": "08:00:00"
},
"nonce": {
"validateNonce": true,
"nonceExpirationInterval": "00:05:00"
}
},
"httpSettings": {
"requireHttps": true,
"routes": {
"apiPrefix": "/.auth"
},
"forwardProxy": {
"convention": "NoProxy"
}
},
"clearInboundClaimsMapping": "false"
}
}
Save the JSON blob above to a file. Then, you can invoke a command like this:
az rest \
--method put \
--uri "https://management.azure.com/subscriptions/YOUR_SUB/resourceGroups/YOUR_RG/providers/Microsoft.Web/sites/YOUR_FUNCTION_NAME/config/authsettingsV2/?api-version=2020-12-01" \
--body @app.json
Where app.json
is the name of your JSON file. If everything went well, you should get a confirmation in the terminal of your new configuration:

authSettingsV2
configuration.Now, when you navigate to the deployed Azure Functions app (you don’t need to redeploy it after changing auth settings), you should be prompted to consent to Azure resource scopes too:

And of course, once you consent, you should be able to see the existing Azure resource groups, just like the original script intended:

If we look inside our JWT now, we see that the aud
and scp
claims are quite different now:
{
"typ": "JWT",
"alg": "RS256",
"x5t": "JDNa_4i4r7FgigL3sHIlI3xV-IU",
"kid": "JDNa_4i4r7FgigL3sHIlI3xV-IU"
}.{
"aud": "https://management.azure.com",
"iss": "https://sts.windows.net/b811a652-39e6-4a0c-b563-4279a1dd5012/",
"iat": 1742346886,
"nbf": 1742346886,
"exp": 1742351496,
"acr": "1",
"aio": "SOME_VALUE",
"altsecid": "SOME_VALUE",
"amr": [
"pwd",
"mfa"
],
"appid": "SOME_VALUE",
"appidacr": "1",
"email": "SOME_VALUE",
"family_name": "SOME_VALUE",
"given_name": "SOME_VALUE",
"groups": [
"SOME_VALUE",
"SOME_VALUE"
],
"idp": "SOME_VALUE",
"idtyp": "user",
"ipaddr": "SOME_VALUE",
"name": "SOME_VALUE",
"oid": "SOME_VALUE",
"puid": "SOME_VALUE",
"rh": "SOME_VALUE",
"scp": "user_impersonation",
"sid": "SOME_VALUE",
"sub": "SOME_VALUE",
"tid": "b811a652-39e6-4a0c-b563-4279a1dd5012",
"unique_name": "SOME_VALUE",
"uti": "SOME_VALUE",
"ver": "1.0",
"wids": [
"SOME_VALUE",
"SOME_VALUE"
],
"xms_edov": true,
"xms_idrel": "14 1",
"xms_tcdt": 1673308801
}.[Signature]
We got what we needed. Congratulations! You now can authenticate users with Azure scopes and use that token to get information about Azure resources the user has access to.
Oh, and of course - this approach can also work with any other scopes that you might need in the future.