Entra Agent ID → Federated Identity Credentials (FIC) deep-dive
An Agent Identity Blueprint uses client credentials to request access tokens via the T1/T2 authentication flow.
T1 → Exchange Token (trust phase)
🔹Secrets/certificates → static credential is trusted (need to be stored somewhere with the risk of being stolen, reused, or misused)❌
🔹Federated Identity Credentials (FIC) → external identity is verified dynamically ✅
T2 → Access Token (authorization phase)
🔹Agent Identity inherits permissions from the Agent Identity Blueprint
🔹Entra issues an Access Token used to access resources
Federated Identity Credentials (FIC) is the most secure method of client credentials since no secrets are stored and authentication is based on trust:
🔹FIC controls T1 → who is trusted
🔹Agent Identity controls T2 → what is allowed
FIC Trust
Every FIC is defined by the same three properties:
🔹issuer → the URL of the external identity provider; must match the iss claim of the external token being exchanged
🔹subject → the identifier of the external software workload within the external identity provider; must match the sub claim within the token presented to Microsoft Entra ID
🔹audiences → the audience that can appear in the external token; should be set to api://AzureADTokenExchange for Microsoft Entra ID
The match performed between the FIC issuer, subject, and audience values and the corresponding values in the token being sent to Microsoft Entra ID is case-sensitive.
No secret ever leaves the workload’s hosting platform. Entra never sees a password. The OIDC token is short-lived (minutes) and is signed by a key the workload cannot extract.
Seeing is believing
Let’s setup a lab to fully understand the Federated Identity Credential authentication flow.
Lab guide
Prerequisites
- PowerShell 7 (required by
Microsoft.Graph.Beta.Applications) - An Azure subscription you can create a resource group in
- An Entra tenant with Agent Identity Blueprint creation
- An account with Agent ID Administrator role (or Global Admin for a lab)
- Modules:
Install-Module Microsoft.Graph.Beta.Applications -Scope CurrentUser -Force
Install-Module Az.Accounts, Az.Resources, Az.ManagedServiceIdentity -Scope CurrentUser -Force1️⃣Create the user-assigned managed identity (MI)
This is the identity that will become the credential for the blueprint. I’m putting it in its own resource group so the blast radius is easy to reason about.
Connect-AzAccount
$rg = "rg-agent-fic-lab"
$loc = "westeurope"
$miName = "mi-agent-blueprint-lab"
New-AzResourceGroup -Name $rg -Location $loc -Force | Out-Null
$mi = New-AzUserAssignedIdentity -ResourceGroupName $rg -Name $miName -Location $loc
$miPrincipalId = $mi.PrincipalId # this is the GUID we'll trust in the FIC
$miClientId = $mi.ClientId # this is what IMDS uses to target the right MI
Write-Host "MI principal ID : $miPrincipalId"
Write-Host "MI client ID : $miClientId"MI principal ID : 2025a774-0315-4085-a535-2b1bdebb3ad5
MI client ID : 8c50f92e-b5ea-4bb4-a0ff-81d39ef841ed2️⃣Create the Agent Identity Blueprint
Connect-MgGraph -Scopes @(
"AgentIdentityBlueprint.Create",
"AgentIdentityBlueprint.AddRemoveCreds.All",
"AgentIdentity.ReadWrite.All"
) -NoWelcome
$blueprintDisplay = "blueprint-fic-lab"
$SponsorUserId = "e837c09a-3bbe-46ab-8259-43635660b9a8"
$OwnerUserId = "e837c09a-3bbe-46ab-8259-43635660b9a8"
$blueprintBody = @{
displayName = $blueprintDisplay
"sponsors@odata.bind" = @("https://graph.microsoft.com/v1.0/users/$SponsorUserId")
"owners@odata.bind" = @("https://graph.microsoft.com/v1.0/users/$OwnerUserId")
} | ConvertTo-Json -Depth 5
$blueprint = Invoke-MgGraphRequest -Method POST `
-Uri "https://graph.microsoft.com/beta/applications/Microsoft.Graph.AgentIdentityBlueprint" `
-Headers @{ "OData-Version" = "4.0"; "Content-Type" = "application/json" } `
-Body $blueprintBody -OutputType PSObject
$blueprintAppId = $blueprint.appId # the OAuth client_id of the blueprint
$blueprintObjId = $blueprint.id # the directory object ID (for the /applications/{id} path)
Write-Host "Blueprint appId : $blueprintAppId"
Write-Host "Blueprint objId : $blueprintObjId"Blueprint appId : 5dc52148-8c9d-48d8-be26-32a8284d0863
Blueprint objId : 5dc52148-8c9d-48d8-be26-32a8284d0863Create the Agent Identity Blueprint Principal
# Create the blueprint PRINCIPAL (service principal) from the blueprint APPLICATION
Connect-MgGraph -Scopes "AgentIdentityBlueprintPrincipal.Create" -NoWelcome
$bpPrincipalBody = @{ appId = $blueprintAppId } | ConvertTo-Json
$blueprintPrincipal = Invoke-MgGraphRequest -Method POST `
-Uri "https://graph.microsoft.com/beta/serviceprincipals/graph.agentIdentityBlueprintPrincipal" `
-Headers @{ "OData-Version" = "4.0"; "Content-Type" = "application/json" } `
-Body $bpPrincipalBody `
-OutputType PSObject
$blueprintSpId = $blueprintPrincipal.id
Write-Host "Blueprint SP (principal) ID : $blueprintSpId"Blueprint SP (principal) ID : d4443997-5e88-425d-9684-b4779cfbe7d3At this point the blueprint exists but has zero credentials. It cannot mint a token.
3️⃣ Add the MI as a Federated Identity Credential on the blueprint
This is the trust declaration. Read it carefully:
$fic = @{
name = "fic-to-lab-mi"
issuer = "https://login.microsoftonline.com/$((Get-MgContext).TenantId)/v2.0"
subject = $miPrincipalId
audiences = @("api://AzureADTokenExchange")
}
New-MgBetaApplicationFederatedIdentityCredential `
-ApplicationId $blueprintObjId `
-BodyParameter $ficId Audiences Description Issuer
-- --------- ----------- ------
9933e16e-ab28-494d-ab4c-a24869d12bbf {api://AzureADTokenExchange} https://login.microsoftonline.com/82d2b6a0-711…issuer→ Entra will ask: "who signed the inbound token?"
It must exactly match theissclaim. For an MI, that'shttps://login.microsoftonline.com/<tenant>/v2.0.subject→ "which workload is this?"
It must exactly match thesubclaim in the inbound token. For an MI, thesubis the MI's principal ID.audience→ "was this token minted for me?"
Must matchaud. Useapi://AzureADTokenExchange— this is the Entra-defined audience for token-exchange flows.
4️⃣Verify the FIC actually attached
Get-MgBetaApplicationFederatedIdentityCredential -ApplicationId $blueprintObjId |
Format-List Name, Issuer, Subject, AudiencesName : fic-to-lab-mi
Issuer : https://login.microsoftonline.com/82d2b6a0-7115-44c1-a787-4e01392b852a/v2.0
Subject : 2025a774-0315-4085-a535-2b1bdebb3ad5
Audiences : {api://AzureADTokenExchange}No passwordCredentials, no keyCredentials. The blueprint genuinely has no secret. You can confirm in the portal:
https://entra.microsoft.com/#view/Microsoft_AAD_RegisteredApps/ApplicationMenuBlade/~/Credentials/appId/<blueprintAppId>
Certificates & secrets should be empty except for the federated credential entry.
5️⃣ Create the Agent Identity
The blueprint is a template. Now we create an agent identity from the agent identity blueprint credentials and permissions.
$agentBody = @{
"@odata.type" = "Microsoft.Graph.AgentIdentity"
displayName = "agent-fic-lab-01"
agentIdentityBlueprintId = $blueprintAppId
"sponsors@odata.bind" = @("https://graph.microsoft.com/v1.0/users/$SponsorUserId")
} | ConvertTo-Json -Depth 5
$agent = Invoke-MgGraphRequest -Method POST `
-Uri "https://graph.microsoft.com/beta/serviceprincipals/Microsoft.Graph.AgentIdentity" `
-Headers @{ "OData-Version" = "4.0"; "Content-Type" = "application/json" } `
-Body $agentBody -OutputType PSObject
$agentSpId = $agent.id # the service principal ID
$agentAppId = $agent.appId # the OAuth client_id of the agent identity itself
Write-Host "Agent SP ID : $agentSpId"
Write-Host "Agent app ID : $agentAppId ← this is the fmi_path target"Agent SP ID : ecc9b650-7415-440f-8a84-5a47477f9e90
Agent app ID : ecc9b650-7415-440f-8a84-5a47477f9e90 ← this is the fmi_path targetGive the agent a permission to test with (app-only User.Read.All on Graph is the ultimate test):
# 1. Get the Microsoft Graph service principal in your tenant
$graphSp = Invoke-MgGraphRequest -Method GET `
-Uri "https://graph.microsoft.com/v1.0/servicePrincipals?`$filter=appId eq '00000003-0000-0000-c000-000000000000'" `
-OutputType PSObject
$graphSp = $graphSp.value[0]
Write-Host "Graph SP Id : $($graphSp.id)"
# 2. Find the User.Read.All app role
$role = $graphSp.appRoles | Where-Object {
$_.value -eq "User.Read.All" -and $_.allowedMemberTypes -contains "Application"
}
Write-Host "Role Id : $($role.id)"
Write-Host "Role Value : $($role.value)"
# 3. Sanity check BEFORE posting — this is the guard that would have caught the 400
if (-not $role -or -not $role.id) {
Write-Error "Could not resolve User.Read.All app role — aborting."
return
}
# 4. Assign the role to the agent
$body = @{
principalId = $agentSpId
resourceId = $graphSp.id
appRoleId = $role.id
} | ConvertTo-Json
Invoke-MgGraphRequest -Method POST `
-Uri "https://graph.microsoft.com/v1.0/servicePrincipals/$agentSpId/appRoleAssignments" `
-Body $body `
-ContentType "application/json"Name Value
---- -----
deletedDateTime
@odata.context https://graph.microsoft.com/v1.0/$metadata#appRoleAssignments/$entity
createdDateTime 18/04/2026 19:47:26
principalId ecc9b650-7415-440f-8a84-5a47477f9e90
id ULbJ7BV0D0SKhFpHR3-ekKeSbY0W1PpIqK3mJ29NYYU
resourceId ad75500a-4875-4ebf-82a8-3b67d86767ea
principalType ServicePrincipal
appRoleId df021288-bdef-4463-88db-98f22de89214
resourceDisplayName Microsoft Graph
principalDisplayName agent-fic-lab-016️⃣Seeing-is-believing
- Assign the MI to an Azure VM (or Container App, or Function → anything with IMDS)
- SSH/run code there
- Exchange the MI token for an agent identity token
- Decode the returned JWT and point at the claims that prove the chain worked
Assign the MI to a (small) Linux VM:
# From your laptop
$vm = Get-AzVM -ResourceGroupName $rg -Name <your-vm>
Update-AzVM -ResourceGroupName $rg -VM $vm `
-IdentityType "UserAssigned" -IdentityID $mi.IdRequestId IsSuccessStatusCode StatusCode ReasonPhrase
--------- ------------------- ---------- ------------
True OKSSH into the VM and run the exchange.
# run the following to install PowerShell on Linux and start PowerShell
sudo snap install powershell --classic
pwsh
# Variables (paste your values from earlier)
$tenantId = "<tenantId>"
$blueprintAppId = "<blueprintAppId>"
$agentAppId = "<agentAppId>"
$miClientId = "<miClientId>"
# ---- (a) Get the MI token from IMDS (this is T_MI) ----
$imdsUri = "http://169.254.169.254/metadata/identity/oauth2/token" +
"?api-version=2018-02-01" +
"&resource=api://AzureADTokenExchange" +
"&client_id=$miClientId"
$miToken = (Invoke-RestMethod -Uri $imdsUri -Headers @{Metadata="true"}).access_token
# Decode it so you can SEE what Entra is about to validate
function Decode-Jwt([string]$jwt) {
$p = $jwt.Split('.')[1].Replace('-','+').Replace('_','/')
switch ($p.Length % 4) { 2 { $p += "==" } 3 { $p += "=" } }
[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($p)) | ConvertFrom-Json
}
Decode-Jwt $miToken | Format-List iss, sub, aud, appidiss : https://login.microsoftonline.com/82d2b6a0-7115-44c1-a787-4e01392b852a/v2.0
sub : 2025a774-0315-4085-a535-2b1bdebb3ad5
aud : fb60f99c-7a34-4190-8149-302f77469936Look at that output. The iss, sub, aud must exactly match the FIC you configured.
From the above token, everything else is as expected except aud (audience) with value fb60f99c-7a34-4190-8149-302f77469936. That value happens to be AAD Token Exchange Endpoint application so it’s equal to api://AzureADTokenExchange
iss : https://login.microsoftonline.com/<tenantId>/v2.0 ← matches FIC.issuer ✅
sub : <miPrincipalId> ← matches FIC.subject ✅
aud : api://AzureADTokenExchange ← matches FIC.audiences ✅Acquire T1 (Exchange Token) via FIC
# Fresh MI token (T1's client_assertion must not be stale)
$imdsUri = "http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=api://AzureADTokenExchange&client_id=$miClientId"
$miToken = (Invoke-RestMethod -Uri $imdsUri -Headers @{Metadata="true"}).access_token
# T1 — this time fmi_path = agent's client ID
$body1 = @{
client_id = $blueprintAppId
scope = "api://AzureADTokenExchange/.default"
fmi_path = $agentAppId # ← the agent, not the literal
client_assertion_type = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
client_assertion = $miToken
grant_type = "client_credentials"
}
try {
$r1 = Invoke-RestMethod -Method POST `
-Uri "https://login.microsoftonline.com/$tenantId/oauth2/v2.0/token" `
-Body $body1 `
-ContentType "application/x-www-form-urlencoded"
$T1 = $r1.access_token
Write-Host "✅ T1 acquired: $($T1.Length) chars" -ForegroundColor Green
Decode-Jwt $T1 | Format-List iss, aud, appid, sub
} catch {
$err = $_.ErrorDetails.Message
Write-Host "❌ T1 failed:" -ForegroundColor Red
Write-Host $err
}✅ T1 acquired: 1478 chars
iss : https://login.microsoftonline.com/82d2b6a0-7115-44c1-a787-4e01392b852a/v2.0
aud : fb60f99c-7a34-4190-8149-302f77469936
sub : /eid1/c/pub/t/oLbSghVxwUSnh04BOSuFKg/a/SCHFXZ2M2Ei-JjKoKE0IYw/ecc9b650-7415-440f-8a84-5a47477f9e90Acquire T2 (Assess Token)
$body2 = @{
client_id = $agentAppId
scope = "https://graph.microsoft.com/.default"
client_assertion_type = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
client_assertion = $T1
grant_type = "client_credentials"
}
try {
$r2 = Invoke-RestMethod -Method POST `
-Uri "https://login.microsoftonline.com/$tenantId/oauth2/v2.0/token" `
-Body $body2 `
-ContentType "application/x-www-form-urlencoded"
$agentToken = $r2.access_token
Write-Host "✅ T2 acquired: $($agentToken.Length) chars" -ForegroundColor Green
Decode-Jwt $agentToken | Format-List iss, aud, appid, sub, oid, roles
} catch {
$err = $_.ErrorDetails.Message
Write-Host "❌ T2 failed:" -ForegroundColor Red
Write-Host $err
}✅ T2 acquired: 2126 chars
iss : https://sts.windows.net/82d2b6a0-7115-44c1-a787-4e01392b852a/
aud : https://graph.microsoft.com
appid : ecc9b650-7415-440f-8a84-5a47477f9e90
sub : ecc9b650-7415-440f-8a84-5a47477f9e90
oid : ecc9b650-7415-440f-8a84-5a47477f9e90
roles : {User.Read.All}7️⃣ Verify the Token
Decode-Jwt $agentToken | Format-List iss, aud, appid, sub, oid, roles, xms_miridiss : https://sts.windows.net/82d2b6a0-7115-44c1-a787-4e01392b852a/
aud : https://graph.microsoft.com
appid : ecc9b650-7415-440f-8a84-5a47477f9e90
sub : ecc9b650-7415-440f-8a84-5a47477f9e90
oid : ecc9b650-7415-440f-8a84-5a47477f9e90
roles : {User.Read.All}What you should see → and what proves the whole chain worked:
iss : https://sts.windows.net/<tenantId>/
aud : https://graph.microsoft.com
appid : <agentAppId> ← the AGENT, not the blueprint, not the MI
sub : <agentSpId> ← SUBJECT is the agent identity's service principal
oid : <agentSpId> ← same
roles : {User.Read.All} ← the permission WE granted to the agent, not to the MIYou authenticated using a managed identity token. But the token you got back is for the agent. Not for the MI. Not for the blueprint. The MI has zero Graph permissions → User.Read.All was granted to the agent identity in Step 5, and that's what roles reflects.
That’s the inheritance model made concrete. The blueprint authenticated itself via the FIC-trusted MI, then told Entra “issue a token for my child agent <agentAppId>" via the fmi_path parameter, and Entra, because the blueprint owns the agent, did exactly that.
Now prove it actually works downstream:
$me = Invoke-RestMethod -Uri "https://graph.microsoft.com/v1.0/users?`$top=3" `
-Headers @{ Authorization = "Bearer $agentToken" }
$me.value | Select displayName, userPrincipalNamedisplayName userPrincipalName
----------- -----------------
Global Administrator <REDACTED>
Admin16 <REDACTED>
My Agent User 01 agentuser01@blue16.nlThree users come back. You just called Graph as an AI agent whose credentials consist entirely of a trust relationship between a blueprint object and a managed identity. No secret was read, written, or transmitted anywhere in that chain.
Verifying in the sign-in logs
Head to Entra admin center → Monitoring → Sign-in logs → Service principal sign-ins. Filter by the agent identity’s appId. You should see:
- Authentication requirement:
Single-factor authentication - Cross-tenant access type:
None - Credential type:
Federated Identity Credential - Service principal name: the agent identity (not the blueprint)
Thanks for reading , and as always, the best way to understand this is to set up a lab for the seeing is believing part 🥷
