A Deep-Dive into Entra Agent ID Authentication
Autonomous agents such as those built on Microsoft Foundry, Microsoft Agent 365, or Microsoft Security Copilot , use the Agent Identity Blueprint platform to authenticate as themselves, following the T1/T2 flow described below.
Interactive agents most commonly built on Microsoft Copilot Studio , use the Agent Identity Blueprint platform as well, but acquire tokens through the OAuth 2.0 On-Behalf-Of (OBO) flow so the agent acts in the signed-in user’s context. In this case, Conditional Access policies targeting the user apply in addition to policies targeting the Agent Identity.
This blog explains the authentication flow for blueprint agents and how it interacts with Conditional Access for Agents.
This blog dives deep in the Entra Agent ID
🔹Authentication Flow
🔹Entra Sign-in Logs
🔹ID Protection for Agents
🔹Conditional Access for Agents
Disclaimer the Entra ID Agent Security products in this blog can be used till May 1st, that’s the official Microsoft Agent 365 license release date which includes ID Protection for Agents and Conditional Access for Agents.
Create a Modern Agent (certificate-based)
First we are going to create an Agent Identity with Certificate credentials to verify everything written in this blog (seeing is believing).
Only FIC or Certificate credentials are supported to T1/T2 extraction, Client Secret is not.
Execute the following four steps in order as described below:
1️⃣ PowerShell prerequisites [once]
2️⃣ Create Agent Blueprint [once]
3️⃣ Create Agent Blueprint Principal [once]
4️⃣ Create Agent Identity [per Agent; max 250]
PowerShell prerequisites
# Install beta module (required - this feature is in preview)
Install-Module Microsoft.Graph.Beta.Applications -Scope CurrentUser -Force
# Connect with all needed scopes
Connect-MgGraph `
-Scopes "AgentIdentityBlueprint.Create",
"AgentIdentityBlueprint.ReadWrite.All",
"AgentIdentityBlueprint.AddRemoveCreds.All",
"AgentIdentityBlueprintPrincipal.Create",
"DelegatedPermissionGrant.ReadWrite.All",
"Application.ReadWrite.All",
"User.Read" `
-TenantId "<your-tenant-id>"Create the Agent Blueprint
$BlueprintDisplayName = "<Agent Blueprint Name>"
$SponsorUserId = "<Sponsor-UserID>"
$OwnerUserId = "<Owner-UserID>"
$body = @{
"@odata.type" = "Microsoft.Graph.AgentIdentityBlueprint"
"displayName" = $BlueprintDisplayName
"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/graph.agentIdentityBlueprint" -Body $body -ContentType "application/json"
$blueprintAppId = $blueprint.appId
$blueprintObjId = $blueprint.id
Write-Host "Blueprint appId: $blueprintAppId"Create the Agent Blueprint Principal
$blueprintAppId = "<agent-blueprint-app-id>"
$body = @{
appId = $blueprintAppId
}
Invoke-MgGraphRequest -Method POST -Uri "https://graph.microsoft.com/beta/serviceprincipals/graph.agentIdentityBlueprintPrincipal" -Headers @{ "OData-Version" = "4.0" } -Body ($body | ConvertTo-Json)Set Blueprint Credentials (Certificate) | Run As Administrator
First we generate self-signed certificate
$certSubject = "CN=BlueprintAuthFlowBlog"
$certValidity = (Get-Date).AddYears(1)
$cert = New-SelfSignedCertificate `
-Subject $certSubject `
-CertStoreLocation "Cert:\CurrentUser\My" `
-KeyExportPolicy NonExportable `
-KeySpec Signature `
-KeyAlgorithm RSA `
-KeyLength 2048 `
-HashAlgorithm SHA256 `
-NotAfter $certValidity
Write-Host "Certificate created"
Write-Host " Thumbprint: $($cert.Thumbprint)"
Write-Host " Subject : $($cert.Subject)"
Write-Host " Expires : $($cert.NotAfter)"
$desktop = [Environment]::GetFolderPath("Desktop")
$cerExportPath = Join-Path $desktop "BlueprintAuthFlowBlog.cer"
Export-Certificate -Cert $cert -FilePath $cerExportPath -Type CERT | Out-Null
Write-Host " Public key: $cerExportPath"
$certThumbprint = $cert.ThumbprintThe next step is to attach the public key to the Blueprint
$tenantId = "<tenant-ID>"
$blueprintAppId = "<agent-blueprint-app-id>"
# Load cert from store and build base64-encoded public key
$cert = Get-Item "Cert:\CurrentUser\My\$certThumbprint"
$base64Key = [Convert]::ToBase64String($cert.GetRawCertData())
# Resolve Blueprint's directory Object ID via filter
$blueprintLookup = Invoke-MgGraphRequest -Method GET `
-Uri "https://graph.microsoft.com/beta/applications?`$filter=appId eq '$blueprintAppId'&`$select=id,appId,displayName"
$blueprintObjectId = $blueprintLookup.value[0].id
Write-Host "Blueprint object ID: $blueprintObjectId"
# PATCH the Blueprint with the key credential
$patchBody = @{
keyCredentials = @(
@{
type = "AsymmetricX509Cert"
usage = "Verify"
key = $base64Key
displayName = "BlueprintAuthFlowBlog-Cert"
}
)
} | ConvertTo-Json -Depth 5
Invoke-MgGraphRequest -Method PATCH `
-Uri "https://graph.microsoft.com/beta/applications/$blueprintObjectId" `
-Body $patchBody `
-ContentType "application/json"
# Verify
Start-Sleep -Seconds 3
$verify = Invoke-MgGraphRequest -Method GET `
-Uri "https://graph.microsoft.com/beta/applications/$blueprintObjectId`?`$select=keyCredentials,passwordCredentials"
Write-Host " keyCredentials count : $($verify.keyCredentials.Count)"
Write-Host " passwordCredentials count: $($verify.passwordCredentials.Count)"The Blueprint now has an AsymmetricX509Cert key credential. The private key lives only in the CurrentUser\My certificate store on the workstation that generated it, it's marked NonExportable, so even with local admin access the raw RSA key can't be pulled out. To authenticate, the Blueprint will sign a short-lived JWT (client assertion) with this private key; Entra ID verifies the signature against the public key.
Set Blueprint identifier URI and scope
$blueprintAppId = "<agent-blueprint-app-id>"
$IdentifierUri = "api://$blueprintAppId"
$ScopeId = [guid]::NewGuid()
$body = @{
identifierUris = @($IdentifierUri)
api = @{
oauth2PermissionScopes = @(
@{
adminConsentDescription = "Allow the application to access the agent on behalf of the signed-in user."
adminConsentDisplayName = "Access agent"
id = $ScopeId.ToString()
isEnabled = $true
type = "User"
value = "access_agent"
}
)
}
} | ConvertTo-Json -Depth 10
# Use BETA endpoint
$uri = "https://graph.microsoft.com/beta/applications/$blueprintAppId"
Invoke-MgGraphRequest -Method PATCH -Uri $uri -Body $body -ContentType "application/json"Create an Agent Identity [up to 250 per Blueprint]
# Variables
$tenantId = "<tenant-ID>"
$blueprintAppId = "<agent-blueprint-app-id>"
$certThumbprint = "<cert-thumbprint-from-1.4a>"
$SponsorUserId = "<Sponsor-UserID>"
$agentIdName = "<Agent-Identity-Name>"
# Connect as the Blueprint using the certificate
Connect-MgGraph `
-TenantId $tenantId `
-ClientId $blueprintAppId `
-CertificateThumbprint $certThumbprint `
-NoWelcome
# Create the Agent Identity
Invoke-MgGraphRequest -Method POST `
-Uri "/beta/servicePrincipals/microsoft.graph.agentIdentity" `
-Body @{
displayName = $agentIdName
agentIdentityBlueprintId = $blueprintAppId
"sponsors@odata.bind" = @("https://graph.microsoft.com/v1.0/users/$SponsorUserId")
} `
-OutputType PSObjectAnd we have created an Agent Identity Blueprint (Certificate credentials) and (one or more) Agent Identities (up to 250).
Agent Identity Authentication Flow
The Agent Identity flow from an Agent in runtime to access (Graph) resources is described below.
1️⃣Authenticate via Blueprint
The Agent Identity runtime authenticates to Entra ID using the Agent Identity Blueprint (which holds the client credentials)
2️⃣Uses Credential
The Blueprint presents its credential (certificate in our case) to Entra ID
3️⃣Issue Credential Token [T1]
Entra ID issues T1 with aud = api://AzureADTokenExchange. T1's sub claim encodes the Blueprint → Agent Identity relationship via an FMI path. T1 is not a resource token; it's a short-lived credential that the Agent Identity will use to request T2.
4️⃣Request Access Token [T2]
The Agent Identity runtime uses T1 as a client_assertion and requests a resource-scoped Access Token T2 from Entra ID
5️⃣Validate and return T2
Entra ID validates T1 against the Agent Identity’s FIC (auto-provisioned at Agent Identity creation), then issues T2 with appid = Agent Identity, appidacr = 2 (certificate authentication preserved from the Blueprint), and xms_par_app_azp = Blueprint AppId (the cryptographic breadcrumb back to the parent).
6️⃣Access Graph resources
The Agent Identity uses T2 as the Bearer token. Graph sees the Agent Identity as the caller; sign-in logs record the call with the Agent Identity as the principal and the Blueprint relationship preserved.
Request T1 & T2 Token(s)
To understand the authentication flow, we first request the T1 (Exchange Token), followed by the T2 (Access Token).
Request T1 (Exchange Token)
$tenantId = "<tenant-ID>"
$blueprintAppId = "<agent-blueprint-app-id>"
$agentIdentityObjectId = "<agent-identity-obj-id>"
$certThumbprint = "<certThumbprint>"
$tokenEndpoint = "https://login.microsoftonline.com/$tenantId/oauth2/v2.0/token"
# --- Look up the Agent Identity's AppId (not the Object ID from the portal) ---
Connect-MgGraph -Scopes "Application.Read.All","Directory.Read.All" -TenantId $tenantId -NoWelcome
$agentSp = Invoke-MgGraphRequest -Method GET `
-Uri "https://graph.microsoft.com/beta/servicePrincipals/$agentIdentityObjectId`?`$select=id,appId,displayName"
$agentIdentityAppId = $agentSp.appId
Write-Host "Agent Identity:"
Write-Host " Display : $($agentSp.displayName)"
Write-Host " ObjectId : $($agentSp.id)"
Write-Host " AppId : $agentIdentityAppId"
Write-Host ""
# --- Load the cert with its private key ---
$cert = Get-Item "Cert:\CurrentUser\My\$certThumbprint"
if (-not $cert.HasPrivateKey) { throw "Private key not available for $certThumbprint" }
# --- Build the signed JWT client assertion ---
function ConvertTo-Base64Url([byte[]]$bytes) {
[Convert]::ToBase64String($bytes) -replace '\+','-' -replace '/','_' -replace '='
}
# x5t = base64url-encoded SHA-1 hash of the cert (tells Entra which cert signed the JWT)
$x5t = ConvertTo-Base64Url $cert.GetCertHash()
$jwtHeader = @{
alg = "RS256"
typ = "JWT"
x5t = $x5t
} | ConvertTo-Json -Compress
$now = [int][double]::Parse((Get-Date (Get-Date).ToUniversalTime() -UFormat %s))
$jwtPayload = @{
aud = $tokenEndpoint
iss = $blueprintAppId
sub = $blueprintAppId
jti = [guid]::NewGuid().ToString()
nbf = $now
exp = $now + 300
} | ConvertTo-Json -Compress
$headerEncoded = ConvertTo-Base64Url ([Text.Encoding]::UTF8.GetBytes($jwtHeader))
$payloadEncoded = ConvertTo-Base64Url ([Text.Encoding]::UTF8.GetBytes($jwtPayload))
$toSign = "$headerEncoded.$payloadEncoded"
$rsa = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($cert)
$signature = $rsa.SignData(
[Text.Encoding]::UTF8.GetBytes($toSign),
[Security.Cryptography.HashAlgorithmName]::SHA256,
[Security.Cryptography.RSASignaturePadding]::Pkcs1
)
$signatureEncoded = ConvertTo-Base64Url $signature
$clientAssertion = "$toSign.$signatureEncoded"
Write-Host "Client assertion built (length: $($clientAssertion.Length) chars)"
# --- Request T1 ---
$body = @{
client_id = $blueprintAppId
scope = "api://AzureADTokenExchange/.default"
fmi_path = $agentIdentityAppId
client_assertion_type = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
client_assertion = $clientAssertion
grant_type = "client_credentials"
}
try {
$response = Invoke-RestMethod `
-Method POST `
-Uri $tokenEndpoint `
-Body $body `
-ContentType "application/x-www-form-urlencoded"
$T1 = $response.access_token
Write-Host ""
Write-Host "T1 acquired. Length: $($T1.Length) chars" -ForegroundColor Green
$T1 | Set-Clipboard
Start-Process "https://jwt.ms"
Write-Host "T1 copied to clipboard. Paste into jwt.ms to inspect."
# Save T1 for the next step
$global:T1 = $T1
} catch {
Write-Host "T1 request failed:" -ForegroundColor Red
$_.ErrorDetails.Message
}Validate T1 by inspecting the JSON Web Token (JWT).
Verification T1 claim value
✔️ aud api://AzureADTokenExchange
✔️ appid/azp Blueprint AppId
✔️ appidacr -
✔️ xms_par_app_azp -
✔️ sub /eid1/c/pub/t/…/a/…/<agentAppId>
Request T2 (Access Token)
# $T1 should still be in scope from the previous script
if (-not $T1) { throw "Re-run the T1 script first" }
$t2Body = @{
client_id = $agentIdentityAppId
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 {
$t2Response = Invoke-RestMethod `
-Method POST `
-Uri $tokenEndpoint `
-Body $t2Body `
-ContentType "application/x-www-form-urlencoded"
$T2 = $t2Response.access_token
Write-Host "T2 acquired (unexpected on first run!). Length: $($T2.Length) chars" -ForegroundColor Green
$T2 | Set-Clipboard
Start-Process "https://jwt.ms"
$global:T2 = $T2
} catch {
Write-Host "T2 request failed (expected on first run):" -ForegroundColor Yellow
$errorBody = $_.ErrorDetails.Message
Write-Host $errorBody
# Extract the expected subject from the error for convenience
if ($errorBody -match "presented assertion subject \\u0027([^\\]+)\\u0027") {
$expectedSubject = $matches[1]
Write-Host ""
Write-Host "Expected FIC subject for Script 3.3:" -ForegroundColor Cyan
Write-Host " $expectedSubject"
$global:ExpectedFicSubject = $expectedSubject
}
}Validate T2 by inspecting the JSON Web Token (JWT).
Verification T2 claim value
✔️ aud https://graph.microsoft.com
✔️ appid/azp Agent Identity AppId
✔️ appidacr 2
✔️ xms_par_app_azp Blueprint AppId
✔️ sub Agent Identity OID
Call Graph
Verify Graph resource access via T2 (Access Token).
# Call Graph as the Agent Identity
$headers = @{ Authorization = "Bearer $T2" }
$me = Invoke-RestMethod -Method GET -Uri "https://graph.microsoft.com/v1.0/servicePrincipals/$agentIdentityObjectId" -Headers $headers
$me | Format-List displayName, appId, id, servicePrincipalTypeSign-in Logs in Entra ID
Make a Graph call to generate a sign-in log entry.
if (-not $T2) { throw "T2 not in scope. Re-run Part 3 first." }
$headers = @{ Authorization = "Bearer $T2" }
# Call Graph as the Agent Identity — read its own SP record
$callTime = Get-Date
Write-Host "Calling Graph at $callTime (UTC: $($callTime.ToUniversalTime()))"
$result = Invoke-RestMethod `
-Method GET `
-Uri "https://graph.microsoft.com/v1.0/servicePrincipals/$agentIdentityObjectId" `
-Headers $headers
Write-Host "Graph call succeeded. Agent Identity read its own record:"
$result | Format-List displayName, appId, id, servicePrincipalType
Write-Host ""
Write-Host "Wait 2-5 minutes before querying sign-in logs — they're not real-time."Entra admin center
- Entra ID → Monitoring & health → Sign-in logs
- Switch to the Service principal sign-ins tab — the Agent Identity authenticated app-only, so this is where its entries land
- Apply these filters:
- Is Agent:
Yes - Agent type:
Agent Identity - Date: Last 24 hours
The audit trail confirms the token.
ID Protection for Agents
In ID Protection for Agents, we can set ‘Confirm Agent Identity as Compromised’ which results in Risk Level 🟥 High.
$tenantId = "<tenant-ID>"
$agentIdentityObjectId = "<agent-identity-obj-id>"
Connect-MgGraph `
-Scopes "IdentityRiskyServicePrincipal.ReadWrite.All",
"IdentityRiskEvent.Read.All",
"IdentityRiskyUser.ReadWrite.All" `
-TenantId $tenantId `
-NoWelcome
$agentEndpoint = "https://graph.microsoft.com/beta/identityProtection/riskyAgents/confirmCompromised"
$agentBody = @{ agentIds = @($agentIdentityObjectId) } | ConvertTo-Json
Write-Host "Attempt 1: Agent-specific risk endpoint"
$compromiseConfirmed = $false
try {
Invoke-MgGraphRequest -Method POST -Uri $agentEndpoint -Body $agentBody -ContentType "application/json"
Write-Host " SUCCESS via /riskyAgents/confirmCompromised" -ForegroundColor Green
$compromiseConfirmed = $true
} catch {
Write-Host " Failed: $($_.Exception.Message)" -ForegroundColor Yellow
}We can confirm the 🟥 High Risk level in the Entra Identity Protection portal under the section Risky agents.
Conditional Access for Agents
Conditional Access for Agents blocks high risk agents.
First, we need to set up a Conditional Access policy to block high risk agent identities.
As Microsoft states: Conditional Access for Agents applies only at the T2 (Access Token) request, not at T1 (Exchange Token).
Second, re-run T2 (Access Token) to see access block by Conditional Access.
$t2Body = @{
client_id = $agentIdentityAppId
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 {
$t2Response = Invoke-RestMethod `
-Method POST `
-Uri $tokenEndpoint `
-Body $t2Body `
-ContentType "application/x-www-form-urlencoded"
Write-Host "T2 issued (CA did NOT block):" -ForegroundColor Yellow
Write-Host " Length: $($t2Response.access_token.Length) chars"
} catch {
Write-Host "T2 BLOCKED — expected after confirmCompromised:" -ForegroundColor Green
$_.ErrorDetails.Message
}If we verify the sign-in logs we confirm the Conditional Access block.
Key takeaways
- Client secrets don’t work for T1/T2 flows , use certificates or FIC
- T1 (
api://AzureADTokenExchange) is a exchange token; T2 (resource) is the access token - The Agent Identity’s parent Blueprint is cryptographically preserved in
xms_par_app_azpand visible in sign-in logs as "Agent parent ID" - Conditional Access evaluates at T2 issuance, T1 is always allowed
- ID Protection + CA = near-realtime revocation
I hope this blog helps you better understand and secure Agent IDs.
