Securing Applications and Automation - Training Series
Section 3 of 6
Reference Guide

Tokens and APIs

Token Acquisition, IMDS, Microsoft Graph, and Azure Resource Manager


Section
Section 3 - Tokens and APIs
Document Type
Reference Guide
Format
Instructor-led with guided lab
Audience
Infrastructure and security administrators

Section purpose

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.

Learning Objectives

Learning Journey 1. Token Acquisition 2. Decode JWT Claims & Scopes 3. Graph API REST Calls 4. Pagination nextLink Loop 5. ARM vs Graph AuthZ Planes GOAL

Learning journey for this section

Requesting authentication and tokens

403 Diagnostic Gap Admin: decode token Read JWT claims roles / aud / oid Root cause found ~5 minutes Admin: no token skills File a ticket wait for L2 / vendor Still waiting hours / days This session closes the gap Target: read any JWT, explain what it authorizes

Why token mechanics matter: closing the 403 diagnostic gap

Managed identity authentication from Azure automation resources

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).

Az PowerShell Connect-AzAccount -Identity Graph SDK Connect-MgGraph -Identity Azure CLI az login --identity IMDS Endpoint 169.254.169.254 Azure-internal, link-local only Access Token (JWT) ~1 hour expiry, scoped to audience No credential stored Platform manages the SP credential lifecycle entirely Audience determines scope graph.microsoft.com/ management.azure.com/ Still needs permissions MI removes credential risk, not the need for least privilege

Managed identity authentication flow

The IMDS endpoint

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.

Azure VM / Function / Runbook HTTP GET 169.254.169.254 IMDS Endpoint 169.254.169.254 Azure-internal only access_token (JWT) use token Microsoft Graph Azure ARM Key Vault / Others ⚠ SSRF Risk IMDS reachable from within the VM only Harden apps against SSRF to IMDS

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
๐Ÿ“šRun Context

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.

โš ๏ธRisk - IMDS Token Theft via SSRF

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.

IMDS Endpoint 169.254.169.254 Graph graph.microsoft.com ARM management.azure.com Key Vault vault.azure.net Graph token ARM token KV token Bearer JWT same pattern, different audience resource= parameter selects the service

Direct IMDS call: same pattern across services, different audience URI

SSRF โ†’ Token Theft Attack Path VM with Managed Identity IMDS reachable internally Attacker Controlled URL โ‘  inject URL VM fetches URL HTTP GET โ†’ attacker redirects Redirected to IMDS 169.254.169.254 Token stolen sent back to attacker Mitigation: Require Metadata:true Bypass proxies and keep the identity least-privileged

SSRF attack path leading to managed identity token theft

Requesting tokens for any Azure service

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/"
Service Audience URI (resource parameter) Auth Model Microsoft Graph Users, groups, mail, Teams, Entra ID https://graph.microsoft.com/ Delegated (scp) or Application (roles) OAuth Consent Azure Resource Manager Subscriptions, resource groups, deployments https://management.azure.com/ RBAC role assignments on scope Azure RBAC Azure Key Vault Secrets, keys, certificates https://vault.azure.net/ RBAC roles on vault or access policies Azure RBAC Log Analytics KQL queries, workspace data https://api.loganalytics.io/ RBAC + workspace-level permissions Azure RBAC Azure Storage Blobs, queues, tables, files https://storage.azure.com/ RBAC data roles on storage account Azure RBAC Pattern: Get-AzAccessToken -ResourceUrl "<audience>" → use token with Invoke-RestMethod

Tokens for any Azure service: choose the audience URI, get the token

Get-AzAccessToken -ResourceUrl Bearer Token eyJ0eXAiOiJKV1Qi... expires in ~1h az account get-access-token IMDS direct call Invoke-RestMethod ~1 hour expiry Cache in $token variable reuse, don't re-request

Acquiring tokens in PowerShell and CLI: cache once, reuse

Decoding a JWT access token

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:

If 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 = Header . Payload . Signature Header alg, typ base64url . Payload claims JSON base64url . Signature HMAC / RSA verify only Decode payload claims aud audience / service roles app permissions scp delegated scopes oid / sub identity object exp expiry unix time PowerShell decode $p = $token.Split('.')[1] $pad = $p.Length % 4; if ($pad) { $p += '=' * (4-$pad) } [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($p)) | ConvertFrom-Json Never paste production tokens into external services jwt.ms is safe only with non-sensitive / test tokens

JWT structure: header, payload, signature - decode the payload

Claim What it tells you If wrong or missing Fix aud Target API (audience) Must match the API you're calling Wrong audience = wrong token entirely Request token for correct URI roles Application permissions granted Present in app-only (client_credentials) tokens Empty = missing app permission or admin consent not yet granted Grant permission + admin consent scp Delegated scopes Should be absent in MI (app-only) tokens Present unexpectedly = interactive user session mixed in Use -Identity flag, not user auth oid Service principal object ID Correlate with Entra sign-in logs Wrong SP = wrong MI selected (user-assigned vs system-assigned) Specify clientId for user-assigned MI exp Token expiry (Unix timestamp) Typically ~1 hour from issuance In the past = token expired Acquire a new token

JWT decode: key claims for diagnosing 403 and permission errors

403 Forbidden Is aud correct? graph.microsoft.com NO Wrong token! Request token for correct resource URI YES roles populated? Check for required permission NO / EMPTY Missing permission Grant app permission + admin consent YES exp still valid? Compare to current UTC time EXPIRED Token expired Acquire a new token VALID scp present? Should be absent for MI YES Wrong auth flow Using delegated instead of app-only ABSENT ✓ Check API-specific e.g. Exchange RBAC, Sites.Selected scope Step 0: Decode the JWT PowerShell or jwt.ms (never external)

Diagnosing a 403 from Graph: decision tree once the token is decoded

? ? What does the token show? aud = graph.microsoft.com โœ“ roles = [ ] (empty) User.Read.All missing! What must be done to fix this? You

Discussion: walk through a 403 diagnostic scenario

MSAL and the Microsoft Authentication Library

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 (Microsoft Authentication Library) Az PowerShell Graph SDK Token Cache Valid token? Return cached. Expired? Request new silently. Good Pattern $tok = Get-AzAccessToken foreach ($u in $users) { call-api $tok } token fetched once Bad Pattern foreach ($u in $users) { $tok = Get-AzAccessToken call-api $tok } new request every iteration Silent Refresh Timeline refresh exp issued ~50 mins valid window

MSAL handles token caching and silent refresh automatically

Microsoft Graph API

Microsoft Identity Microsoft Graph graph.microsoft.com Azure ARM management.azure.com Key Vault vault.azure.net Log Analytics api.loganalytics.io Storage / Service Bus *.core.windows.net OAuth Consent Azure RBAC RBAC Roles RBAC Roles RBAC / SAS tokens

Microsoft Graph in the broader Microsoft Identity API landscape

Versioned endpoints

Microsoft Graph has two endpoint tracks:

A production Graph call looks like:

GET https://graph.microsoft.com/v1.0/users
/v1.0 Preferred starting point โ–ธ GA features - SLA-backed โ–ธ Stable, versioned contracts โ–ธ No breaking changes โ–ธ Default for new runbooks /beta Not supported for production use Beta version support โ–ธ Features may appear here before /v1.0 โ–ธ Contracts can change without notice โ–ธ Re-test before rollout Operational guidance โ–ธ Default to /v1.0 whenever possible โ–ธ Document any /beta dependency โ–ธ Monitor changelog and release notes features gradually graduate to /v1.0 How to choose 1. Default to /v1.0 - covers most automation needs 2. Need /beta? Treat it as a support-risk exception 3. Re-test often and watch for schema changes 4. Mixing versions? /beta and /v1.0 types can differ - may require all calls in one version Support: learn.microsoft.com/graph/versioning-and-support#beta-version Monitor: learn.microsoft.com/graph/changelog both endpoints use the same token scopes and consent

Graph API versioning: prefer /v1.0; use /beta only with explicit risk acceptance

OData query parameters

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.

GET /v1.0/users ?$select=id,displayName &$filter=... &$top=25 $select Return only named fields Reduce payload dramatically $filter Server-side filtering Not client-side - do at API $top Page size control Max varies by endpoint $expand Inline related entities One call vs. N+1 calls $count=true Total result count in response header Requires: ConsistencyLevel: eventual header Without $select { id, displayName, mail, jobTitle, mobilePhone, ... } ~40+ fields per object With $select=id,mail { id, mail } 2 fields - 95% smaller

OData query parameters cheat sheet

Graph Explorer

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:

developer.microsoft.com/graph/graph-explorer GET /v1.0/users?$select=id,mail Run { Requires: User.Read.All Test before writing automation code explore structure ยท verify permissions

Graph Explorer: test queries before writing automation

Pagination with @odata.nextLink

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)"
GET /users Response Page value: [ user1, user2, ... ] @odata.nextLink: URL (absent on last page) nextLink present โ†’ GET next page Append results All pages collected โœ“ โš  Common bug:No loop โ†’ first page only

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.

Silent Data Loss - Pagination Bug Tenant: 2,000 users total Page 1: 999 users Page 2: 1,001 users (missed) No Pagination Loop $resp = Invoke-RestMethod ## STOPS HERE Result: 999 records Report "looks complete" 1,001 users missed With Pagination Loop do { $resp = Invoke-RestMethod $url $url = $resp.'@odata.nextLink' } while ($url) All 2,000 users Why this is insidious No error. No warning. Output file size looks plausible. Compliance report passes - but 1,001 users are unchecked. Signal: @odata.nextLink present in response Always check for it. Loop until absent. Rule: every Graph collection call must have a pagination loop No exception - even small tenants eventually grow

Pagination failure mode: silent data loss in compliance reports

Throttling

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.

1 GET /users (any Graph call) 2 HTTP 429 Too Many Requests Retry-After: 30 3 sleep Retry-After seconds Start-Sleep -Seconds $retryAfter 4 GET /users (retry) 5 200 OK ✓ Key points Always honor the Retry-After value - do not use fixed delays Do not assume your client stack retries 429s for you Limits are per-app and per-tenant (not global) Bulk extraction at scale: consider Graph Data Connect No Retry-After header? Use exponential backoff

Graph throttling and 429 handling: honor Retry-After

Azure Resource Manager

Managed Identity / Service Principal Workload Identity Token for ARM Token for Graph Azure Resource Manager management.azure.com Auth Model Azure RBAC roles Granted via IAM → role assignment Scoped to MG / Sub / RG / resource Token claim RBAC checked at ARM, not JWT Microsoft Graph graph.microsoft.com Auth Model OAuth app permissions Granted via App registration + admin consent Scoped to Tenant-wide across instances Token claim roles claim in JWT RBAC ≠ Graph - one does not grant the other

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
โš ๏ธRBAC Note

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.

Which API endpoint? management.azure.com Azure RBAC Role assignment at subscription / RG scope graph.microsoft.com Graph Consent App permission + admin consent 403 from ARM Check RBAC role assignments in IAM 403 from Graph Decode token โ†’ check roles claim Calling Both APIs? Need BOTH authorizations. RBAC does not grant Graph consent. They are separate authorization planes. Diagnose each independently when troubleshooting.

ARM versus Graph: the practical test for diagnosing 403 errors

Internal and undocumented APIs

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.

Official APIs Microsoft Learn docs Internal APIs Found via DevTools Network Inspector All requests captured here incl. undocumented calls 🔍 May break any update ROAD CLOSED without warning Use official APIs Never build prod automation on undocumented endpoints

Internal and undocumented APIs: discoverable but unsupported

Lab readiness notes

๐ŸงชLab Readiness

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.

Guided lab

Lab goal

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.

Lab prerequisites

Step 0: Grant the managed identity the Graph permission the lab expects

Use 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.

Azure Portal:

Confirm the managed identity principal ID
  1. Open the Automation Account.
  2. Go to Identity and confirm System assigned is On.
  3. Copy the Object (principal) ID so you can verify the same service principal in Entra after the grant.

Native PowerShell:

Grant User.Read.All through 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-MgGraphRequest
Connect-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"
}

Step 1: Connect using managed identity and call Graph through Graph PowerShell

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.

Step 2: Acquire a raw token and call Graph via REST

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.

Step 3: Decode the JWT and inspect claims

Azure Automation runbook:

Confirm roles versus scp
$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:

Paste the token into https://jwt.ms if you want to verify the same claims visually in the browser.

Step 4: Implement paginated Graph calls

Azure Automation runbook:

Follow @odata.nextLink until 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.

Step 5: Add field selection to reduce response size

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 1 IMDS = credential-free foundation No secrets to rotate, no creds to leak 2 Protect IMDS access, use least-privileged roles Metadata header, proxy bypass, local exposure control 3 Decode token first - never guess 5 minutes with JWT vs. hours waiting for L2 4 Always paginate Graph collection calls Missing loop = silent data loss 5 ARM RBAC โ‰  Graph consent Two planes - diagnose separately These five rules resolve 90% of managed identity issues

Admin takeaways: 5 principles to remember from this section

Quick recap questions

  1. Which HTTP endpoint does Connect-AzAccount -Identity call internally to obtain a token?
  2. What claim in a JWT tells you an application permission is present? What claim tells you a delegated scope is present?
  3. What does a Graph API 429 response mean, and what should your code do when it receives one?
  4. If a runbook returns a 403 when calling GET /v1.0/users, which token claim would you inspect first to diagnose the issue?
  5. Why should you never rely on an internal, undocumented Microsoft API in production automation?

Key reminders

Section 3 Tokens & APIs ✓ Complete Section 4 Runbooks & IaC Coming up → Automation Runbooks token + API patterns applied to real tasks IaC for Automation Accounts managed identity + permissions via code Monitoring & Alerting runbook failure detection and notification Azure Developer CLI packaging and deploying automation Up Next: Section 4 Building and Operating Runbooks managed identity ‧ IaC ‧ Azure Developer CLI ✓ Section 3 complete

Bridge to Section 4: building and operating runbooks

Current source notes

Volatile platform behavior and dated claims in this section were checked against these current sources on April 26, 2026:

References

References Appendix

All links below were reviewed on 2026-03-10.

Managed identity and IMDS

MSAL and token handling

Microsoft Graph API

Azure Resource Manager

JWT inspection and security

Community and MVP references

Section 3 - Tokens and APIs - Reference Guide