Token Acquisition, IMDS, Microsoft Graph, and Azure Resource Manager
This section bridges the identity and permission model from Sections 1 and 2 into practical automation. The focus is on how workloads acquire tokens, how to inspect those tokens to diagnose permission problems, and how to call the Microsoft Graph API and other Azure APIs correctly and efficiently. You do not need a developer background. Everything in this section is taught from first principles.
@odata.nextLinkLearning journey for this section
Why token mechanics matter: closing the 403 diagnostic gap
Managed identities are the preferred authentication method for Azure-hosted automation. The credential is platform-managed and never stored in code. The authentication flow is the same whether you are using an Automation Account, Function App, or Azure VM.
Key PowerShell cmdlets:
# Authenticate Az PowerShell using the managed identity of the current resource
Connect-AzAccount -Identity
# Authenticate the Microsoft Graph PowerShell SDK using managed identity
Connect-MgGraph -Identity
# Azure CLI equivalent
az login --identity
All three of these ultimately call the same underlying mechanism: the Azure Instance Metadata Service (IMDS).
Managed identity authentication flow
IMDS is a local HTTP endpoint available only from within Azure:
http://169.254.169.254/metadata/identity/oauth2/token
It returns a bearer token for the requested resource without any stored credential. Because it is a link-local address, it is not reachable from outside the Azure infrastructure.
IMDS endpoint: link-local token source reachable only from inside Azure
A direct IMDS call looks like this:
$response = Invoke-RestMethod `
-Method Get `
-NoProxy `
-Uri "http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://graph.microsoft.com/" `
-Headers @{ Metadata = "true" }
$token = $response.access_token
These IMDS and -Identity examples only work on an Azure resource that actually has a managed identity. Running them from a local device fails because the IMDS endpoint is not present there.
VMs with managed identities are vulnerable to Server-Side Request Forgery (SSRF) attacks that reach the IMDS endpoint. An attacker who can cause the VM to make an outbound HTTP request to a URL they control can redirect it to the IMDS endpoint and steal the managed identity token. Azure's documented defenses are the required Metadata: true header, rejection of X-Forwarded-For, bypassing proxies with -NoProxy, and local firewall rules that restrict which processes can reach IMDS.
Direct IMDS call: same pattern across services, different audience URI
SSRF attack path leading to managed identity token theft
Managed identity tokens are scoped to a specific resource (audience). You can request tokens for any Azure service:
| Service | Resource URI |
|---|---|
| Microsoft Graph | https://graph.microsoft.com/ |
| Azure Resource Manager | https://management.azure.com/ |
| Azure Key Vault | https://vault.azure.net/ |
| Log Analytics | https://api.loganalytics.io/ |
Using Az PowerShell:
# Get a token for Microsoft Graph
$graphToken = Get-AzAccessToken -ResourceUrl "https://graph.microsoft.com/"
# Get a token for Azure Resource Manager
$armToken = Get-AzAccessToken -ResourceUrl "https://management.azure.com/"
Using Azure CLI:
az account get-access-token --resource "https://graph.microsoft.com/"
Tokens for any Azure service: choose the audience URI, get the token
Acquiring tokens in PowerShell and CLI: cache once, reuse
You can decode a JWT in PowerShell to inspect its claims. This is useful for diagnosing permission errors.
$tokenParts = $token.Split(".")
$payload = $tokenParts[1].Replace("-", "+").Replace("_", "/")
switch ($payload.Length % 4) {
2 { $payload += "==" }
3 { $payload += "=" }
}
$claims = [Text.Encoding]::UTF8.GetString(
[Convert]::FromBase64String($payload)
) | ConvertFrom-Json
$claims | Select-Object aud, oid, scp, roles, exp
Claims to look for when diagnosing a 403 error:
aud: should match the target API (https://graph.microsoft.com/ for Graph calls)roles: should list the application permissions granted to the managed identityscp: should be absent for managed identity (app-only) callsexp: should be in the future (Unix timestamp); managed identity token lifetimes can vary, so do not assume a fixed one-hour expiryIf a runbook returns 403 Forbidden when calling Graph, the most common cause is that roles is empty or does not include the required permission. The managed identity needs the Graph application permission granted via admin consent.
JWT structure: header, payload, signature - decode the payload
JWT decode: key claims for diagnosing 403 and permission errors
Diagnosing a 403 from Graph: decision tree once the token is decoded
Discussion: walk through a 403 diagnostic scenario
MSAL is the recommended authentication library for custom code. It handles:
The Microsoft Graph PowerShell SDK and Az PowerShell both use MSAL internally. For Python, .NET, or Node.js automation, MSAL abstracts away token acquisition complexity. Retry behavior for throttling depends on the SDK or client pipeline, so raw REST automation still needs to honor Retry-After itself.
MSAL handles token caching and silent refresh automatically
Microsoft Graph in the broader Microsoft Identity API landscape
Microsoft Graph has two endpoint tracks:
/v1.0: stable, production-ready features. Prefer this as the starting point for all automation./beta: contains features not yet available in /v1.0, but Microsoft states the beta version is not supported for production use. See the beta version support policy./beta, document the dependency, test it carefully, and monitor the Graph changelog for announced changes./v1.0 and /beta in a single runbook can cause object type mismatches. If a key feature is only in /beta, consider using /beta for all calls in that runbook to ensure type consistency.A production Graph call looks like:
GET https://graph.microsoft.com/v1.0/users
Graph API versioning: prefer /v1.0; use /beta only with explicit risk acceptance
Graph API uses OData query parameters to filter, shape, and sort responses:
| Parameter | Purpose | Example |
|---|---|---|
$select |
Return only specified fields | $select=displayName,userPrincipalName |
$filter |
Filter results | $filter=accountEnabled eq false |
$top |
Limit page size | $top=100 |
$orderby |
Sort results | $orderby=displayName |
$expand |
Expand related entities | $expand=manager |
$count |
Return total count | $count=true |
Use $select whenever possible to reduce response payload size and API call latency.
OData query parameters cheat sheet
Graph Explorer is the interactive tool for testing Graph API calls with real tenant data before writing code:
https://developer.microsoft.com/en-us/graph/graph-explorer
Use it to:
$filter, $select, and $expand syntaxGraph Explorer: test queries before writing automation
Graph API returns paginated results for large collections. When more results exist beyond the current page, the response includes an @odata.nextLink property containing the URL for the next page.
$uri = "https://graph.microsoft.com/v1.0/users?`$top=5"
$headers = @{ Authorization = "Bearer $token" }
$allUsers = @()
do {
$response = Invoke-RestMethod -Uri $uri -Headers $headers
$allUsers += $response.value
$uri = $response.'@odata.nextLink'
} while ($uri)
Write-Output "Total users retrieved: $($allUsers.Count)"
Graph Pagination
Without pagination handling, a runbook silently returns only the first page. This is a common bug in Graph automation that produces incomplete compliance reports or inaccurate user inventories.
Pagination failure mode: silent data loss in compliance reports
Graph API enforces per-app, per-tenant rate limits. When you exceed the limit, you receive an HTTP 429 Too Many Requests response with a Retry-After header indicating how many seconds to wait.
Correct handling:
$uri = "https://graph.microsoft.com/v1.0/users"
$headers = @{ Authorization = "Bearer $token" }
do {
try {
$response = Invoke-RestMethod -Uri $uri -Headers $headers
break
} catch {
if ($_.Exception.Response.StatusCode -eq 429) {
$retryAfter = $_.Exception.Response.Headers["Retry-After"]
Start-Sleep -Seconds ([int]$retryAfter + 1)
} else {
throw
}
}
} while ($true)
For bulk data extraction such as full sign-in logs, prefer Microsoft Graph Data Connect over repeated REST calls. Graph Data Connect is a bulk extraction alternative designed for large-scale analytics pipelines.
Graph throttling and 429 handling: honor Retry-After
ARM and Graph: separate authorization models - granting one does not grant the other
Azure Resource Manager (ARM) is the management and deployment layer for Azure resources. Azure portal, Azure CLI, Az PowerShell, Bicep, and ARM templates all rely on it for control-plane operations against https://management.azure.com/.
The key difference from Microsoft Graph:
A direct ARM REST call:
$armToken = Get-AzAccessToken -ResourceUrl "https://management.azure.com/"
$armBearerToken = if ($armToken.Token -is [securestring]) {
[System.Net.NetworkCredential]::new('', $armToken.Token).Password
} else {
$armToken.Token
}
$subscriptionId = "<subscription-id>"
$response = Invoke-RestMethod `
-Uri "https://management.azure.com/subscriptions/$subscriptionId/resourceGroups?api-version=2021-04-01" `
-Headers @{ Authorization = "Bearer $armBearerToken" }
$response.value | Select-Object name, location
This call also requires Azure RBAC on the target scope, such as Reader on the subscription or resource group. Graph permissions do not authorize ARM.
Understanding ARM as the underlying layer explains why tokens scoped to https://management.azure.com/ are needed for infrastructure automation and why Azure RBAC governs that access rather than consent.
ARM versus Graph: the practical test for diagnosing 403 errors
Many Microsoft first-party services such as Teams, SharePoint, and the Outlook web application use internal REST APIs that are not documented on Microsoft Learn. These are discoverable through browser developer tools network inspection.
These APIs:
The correct approach is to wait for Microsoft Graph coverage of the functionality you need, or use supported libraries such as PnP PowerShell for SharePoint and Teams scenarios. Internal APIs are useful for prototyping and reverse engineering your own tenant's behavior, but they are not a stable foundation for operational automation.
Internal and undocumented APIs: discoverable but unsupported
Have access to the managed identity lab resource or the prepared token/Graph response samples. If live token acquisition fails, use the saved JWT payload to practice claim reading and the saved Graph response to practice pagination and $select reasoning. Do not paste real production tokens into public tools.
Connect to Microsoft Graph from an Automation Account runbook using managed identity, verify the app-only permission in the token, compare Graph PowerShell to raw REST, and implement pagination and $select correctly.
Az.Accounts and Microsoft.Graph.Authentication importedUser.Read.All application permission to the Automation Account identityUse the Automation Account principal ID as the target for the app role assignment. The later runbook steps assume the managed identity already has User.Read.All.
Identity and confirm System assigned is On.Object (principal) ID so you can verify the same service principal in Entra after the grant.Native PowerShell:
Grant
User.Read.Allthrough Microsoft Graph$resourceGroupName = "<resource-group>" $automationAccountName = "<automation-account>" $managedIdentitySpObjectId = az automation account show ` --automation-account-name $automationAccountName ` --resource-group $resourceGroupName ` --query identity.principalId ` -o tsv $adminAccessToken = az account get-access-token ` --resource-type ms-graph ` --query accessToken ` -o tsv $graphSp = Invoke-RestMethod ` -Method Get ` -Uri "https://graph.microsoft.com/v1.0/servicePrincipals?`$filter=appId eq '00000003-0000-0000-c000-000000000000'&`$select=id,appRoles" ` -Headers @{ Authorization = "Bearer $adminAccessToken" } $graphSpId = $graphSp.value[0].id $userReadAllRoleId = ($graphSp.value[0].appRoles | Where-Object { $_.value -eq "User.Read.All" -and $_.allowedMemberTypes -contains "Application" } | Select-Object -First 1).id $existingAssignments = Invoke-RestMethod ` -Method Get ` -Uri "https://graph.microsoft.com/v1.0/servicePrincipals/$managedIdentitySpObjectId/appRoleAssignments" ` -Headers @{ Authorization = "Bearer $adminAccessToken" } if (-not ($existingAssignments.value | Where-Object { $_.resourceId -eq $graphSpId -and $_.appRoleId -eq $userReadAllRoleId })) { Invoke-RestMethod ` -Method Post ` -Uri "https://graph.microsoft.com/v1.0/servicePrincipals/$managedIdentitySpObjectId/appRoleAssignments" ` -Headers @{ Authorization = "Bearer $adminAccessToken"; "Content-Type" = "application/json" } ` -Body (@{ principalId = $managedIdentitySpObjectId resourceId = $graphSpId appRoleId = $userReadAllRoleId } | ConvertTo-Json) }
Graph PowerShell:
Grant the same app role with
Invoke-MgGraphRequestConnect-MgGraph -Scopes "AppRoleAssignment.ReadWrite.All","Application.Read.All" $resourceGroupName = "<resource-group>" $automationAccountName = "<automation-account>" $managedIdentitySpObjectId = az automation account show ` --automation-account-name $automationAccountName ` --resource-group $resourceGroupName ` --query identity.principalId ` -o tsv $graphSp = Invoke-MgGraphRequest ` -Method GET ` -Uri "https://graph.microsoft.com/v1.0/servicePrincipals?`$filter=appId eq '00000003-0000-0000-c000-000000000000'&`$select=id,appRoles" $graphSpId = $graphSp.value[0].id $userReadAllRoleId = ($graphSp.value[0].appRoles | Where-Object { $_.value -eq "User.Read.All" -and $_.allowedMemberTypes -contains "Application" } | Select-Object -First 1).id $existingAssignments = Invoke-MgGraphRequest ` -Method GET ` -Uri "https://graph.microsoft.com/v1.0/servicePrincipals/$managedIdentitySpObjectId/appRoleAssignments" if (-not ($existingAssignments.value | Where-Object { $_.resourceId -eq $graphSpId -and $_.appRoleId -eq $userReadAllRoleId })) { Invoke-MgGraphRequest ` -Method POST ` -Uri "https://graph.microsoft.com/v1.0/servicePrincipals/$managedIdentitySpObjectId/appRoleAssignments" ` -Body @{ principalId = $managedIdentitySpObjectId resourceId = $graphSpId appRoleId = $userReadAllRoleId } }
Az CLI:
Use a file-backed JSON body to avoid PowerShell quoting issues
$resourceGroupName = "<resource-group>" $automationAccountName = "<automation-account>" $managedIdentitySpObjectId = az automation account show ` --automation-account-name $automationAccountName ` --resource-group $resourceGroupName ` --query identity.principalId ` -o tsv $graphSpId = az ad sp list ` --filter "appId eq '00000003-0000-0000-c000-000000000000'" ` --query "[0].id" ` -o tsv $userReadAllRoleId = az rest ` --method get ` --uri "https://graph.microsoft.com/v1.0/servicePrincipals/$graphSpId/appRoles?`$select=id,value,allowedMemberTypes" ` --query "value[?value=='User.Read.All' && contains(allowedMemberTypes, 'Application')].id | [0]" ` --output tsv $assignmentBodyPath = Join-Path $env:TEMP "section3-user-read-all.json" (@{ principalId = $managedIdentitySpObjectId resourceId = $graphSpId appRoleId = $userReadAllRoleId } | ConvertTo-Json -Compress) | Set-Content -Path $assignmentBodyPath -Encoding ascii $existingAssignment = az rest ` --method get ` --uri "https://graph.microsoft.com/v1.0/servicePrincipals/$managedIdentitySpObjectId/appRoleAssignments" ` --query "value[?resourceId=='$graphSpId' && appRoleId=='$userReadAllRoleId'] | [0]" ` --output json if ($existingAssignment -eq "null") { az rest ` --method post ` --uri "https://graph.microsoft.com/v1.0/servicePrincipals/$managedIdentitySpObjectId/appRoleAssignments" ` --headers "Content-Type=application/json" ` --body "@$assignmentBodyPath" }
Keep this and the later steps in the same PowerShell 7.4 runbook so you can reuse the token and headers as you progress.
Azure Automation runbook:
Connect with managed identity and query Graph
Disable-AzContextAutosave -Scope Process | Out-Null Connect-AzAccount -Identity | Out-Null Connect-MgGraph -Identity -NoWelcome | Out-Null $users = Invoke-MgGraphRequest ` -Method GET ` -Uri "https://graph.microsoft.com/v1.0/users?`$top=5&`$select=id,displayName,userPrincipalName" $users.value | Select-Object displayName, userPrincipalName | Format-Table
Expected outcome: a short table of users from the tenant. If you receive a 403, return to Step 0 and inspect the managed identity's roles claim in Step 3.
Azure Automation runbook:
Get the Graph token explicitly and call
/users$tokenObject = Get-AzAccessToken -ResourceTypeName MSGraph $token = if ($tokenObject.Token -is [securestring]) { [System.Net.NetworkCredential]::new('', $tokenObject.Token).Password } else { $tokenObject.Token } $headers = @{ Authorization = "Bearer $token" } $response = Invoke-RestMethod ` -Method Get ` -Uri "https://graph.microsoft.com/v1.0/users?`$top=5&`$select=id,displayName,userPrincipalName" ` -Headers $headers $response.value | Select-Object displayName, userPrincipalName
Expected outcome: the same data appears again, but now you can see the raw response shape and the bearer token boundary directly.
Azure Automation runbook:
Confirm
rolesversusscp$tokenParts = $token.Split(".") $payload = $tokenParts[1].Replace("-", "+").Replace("_", "/") switch ($payload.Length % 4) { 2 { $payload += "==" } 3 { $payload += "=" } } $claims = [Text.Encoding]::UTF8.GetString( [Convert]::FromBase64String($payload) ) | ConvertFrom-Json $claims | Select-Object aud, oid, roles, scp, exp | Format-List
Expected observations:
aud is https://graph.microsoft.comroles contains User.Read.Allscp is absent or empty because this is app-only accessoid matches the Automation Account service principal object IDPaste the token into https://jwt.ms if you want to verify the same claims visually in the browser.
Azure Automation runbook:
Follow
@odata.nextLinkuntil the collection is complete$uri = "https://graph.microsoft.com/v1.0/users?`$top=2&`$select=displayName,userPrincipalName" $allUsers = @() do { $page = Invoke-RestMethod -Method Get -Uri $uri -Headers $headers $allUsers += $page.value $uri = $page.'@odata.nextLink' Write-Output "Retrieved $($allUsers.Count) users so far..." } while ($uri) Write-Output "Total users retrieved: $($allUsers.Count)"
Expected outcome: multiple loop iterations when $top=2 is low enough to force pagination.
Azure Automation runbook:
Request only the fields the runbook actually needs
$response = Invoke-RestMethod ` -Method Get ` -Uri "https://graph.microsoft.com/v1.0/users?`$select=displayName,userPrincipalName,accountEnabled" ` -Headers $headers $response.value | Where-Object { -not $_.accountEnabled } | Select-Object displayName, userPrincipalName
Expected outcome: the response payload is smaller and the runbook only processes the properties it asked for.
Admin takeaways: 5 principles to remember from this section
Connect-AzAccount -Identity call internally to obtain a token?429 response mean, and what should your code do when it receives one?GET /v1.0/users, which token claim would you inspect first to diagnose the issue?Metadata: true and should bypass proxies with -NoProxyroles in the token = application permission; scp = delegated scope; one should be absent depending on the auth model@odata.nextLink in Graph calls or risk silently incomplete resultsBridge to Section 4: building and operating runbooks
Volatile platform behavior and dated claims in this section were checked against these current sources on April 26, 2026:
All links below were reviewed on 2026-03-10.
Section 3 - Tokens and APIs - Reference Guide