Entra ID – Deep Dive – Entra ID Authentication – Part 3

Entra ID – Deep Dive – Entra ID Authentication – Part 3

This is part of my series on Microsoft Entra ID:

  1. Entra ID – Deep Dive – The Basics – Part 1
  2. Entra ID – Deep Dive – Protocol Primer – Part 2
  3. Entra ID – Deep Dive – Entra ID Authentication – Part 3

Back for more are ya? Today we’re gonna chat about how you could add Entra ID authentication into your custom-built web application. I’ll be digging into the Entra ID application registration process and examining the requests and responses for the whole authentication process via a local proxy using HTTP Toolkit. The goal here is not to give you coding best practices (god save you if you use any of my code in production) but instead to help you understand how all this stuff works and how products are (and are not) exercising the OIDC and OAuth protocols under the hood.

If you haven’t read my first and second post in the series, stop what you’re doing right now and read them. I’m going into this post assuming you have and thus assuming knowledge and understanding basic Entra ID concepts like applications vs service principals and a foundational understanding of OIDC and OAuth.

The solution design I’m building towards this in this series of posts is a simple frontend web application and backend API that are using Entra ID for authentication and authorization. The end design will look something like the below. This post will focus on the frontend web application.

Series solution design

Creating the frontend application registration

As I covered in my first post, the application registration (or application object) is the globally unique representation of the application across Entra ID. There can only be one application registration for an application across all of Entra. An application registration can be single tenant (used only in your Entra ID tenant) or multi-tenant (can be used across Entra ID tenants). I like to think of the process of creating the application process similar to the manual client registration process mentioned in the OAuth spec. The result is the same as we’ll configure a bunch of information required for OAuth such as the redirect URI, the grant types it supports, and whether the client will be public or confidential client. Once registered, Entra will return a unique client_id and client_secret if a confidential application. There are additional Entra-specific properties we can populate, but the manual client registration explanation makes the most sense in my brain at least.

Creating an application registration can be done through the Portal, CLI/PowerShell, REST, Terraform, etc. I’m going to create it direct through the Microsoft Graph REST API because I want to walk through all the gory properties. To create an app registration, your user account needs to be at least hold the Entra ID Application Developer role. To keep things simple and address my laziness, my user will be setup as a global admin.

# Set the properties for the application
app_display_name = "Demo frontend app for Entra authentication"
description = "This app is used to demonstrate a frontend application where a user authenticates using Entra ID authentication via OIDC"
contact = "business_unit1@jogcloud.com"
........
# Create an app registration
def create_app_registration(display_name: str, contact: str):
"""This function creates a new application registration in Microsoft Graph API if it doesn't already exist
Args:
display_name (str): The display name for the new application registration.
contact (str): The contact information to associate with the application registration.
Returns:
dict: The details of the created application registration.
"""
check_app = get_app_registration_by_display_name(display_name)
if check_app is not None:
print(f"Application {check_app['displayName']} already exists and its id is {check_app['id']}")
return check_app
else:
print("Creating new application registration...")
body = {
"displayName": display_name,
"description": description,
# Setting to false means this is a confidential client application vs a public client
"isFallbackPublicClient": False,
# Set a service management reference which can be the contact associated with the application
"serviceManagementReference": contact,
# Create the app as multi tenant; single tenant would use AzureADMyOrg
"signInAudience": "AzureADMultipleOrgs",
# Add a redirect URI to support OIDC authentication
"web": {
"redirectUris": [
"http://localhost:8100/callback"
]
}
}
response = requests.post(
'https://graph.microsoft.com/v1.0/applications',
headers={
'Content-Type': 'application/json',
'Authorization': f'Bearer {user_token.token}'
},
json=body
)
return response.json()
app_frontend = create_app_registration(app_display_name, contact)
print(json.dumps(app_frontend, indent=2))

The application registration I’m creating above is being created as a multi-tenant application instead of a single tenant application and is determined by the signInAudience being set to a value of AzureADMultipleOrgs. I’m doing this because I may do an additional post in this series walking through multi-tenant applications. Most of the application registrations you create will be single tenant and would have this property set to AzureADMyOrg.

Since I’m building a web application, I’m going to be configuring it as a confidential client (which means it will have a credential) and I’m going to use the authorization code flow. I don’t want my application registration to ever support being used as a public client so I set isFallbackPublicClient to false. This will force my client to provide a credential when attempting to obtain a token. If you were building an application that would live direct on the user’s desktop or mobile device, you’d need to set to that true because at that point your application would be a public client.

Under the web property, I’m setting the redirectUri property to the endpoint in my application I want the user redirected to after the user successfully authenticates to Entra and consents to whatever access I’m requesting (if consent is required). In this case, my application runs directly on my machine so this is set to localhost.

You’ll also see I’m setting the serviceManagementReference property. Best practice is for you to set this property with a contact within the business unit for that owns the application. This can be helpful if the application registration becomes stale at this point and you detect that during your regular audits (which OF COURSE you’re doing!)

Once complete, I get the response below.

Creating new application registration...
{
"@odata.context": "https://graph.microsoft.com/v1.0/$metadata#applications/$entity",
"id": "be0af053-faf4-44b3-b071-XXXXXXXX",
"deletedDateTime": null,
"appId": "fc815c55-d456-4d38-be76-XXXXXXXX",
"applicationTemplateId": null,
"disabledByMicrosoftStatus": null,
"createdByAppId": "04b07795-8ddb-461a-XXXXXXXXXXXX",
"createdDateTime": "2026-06-25T01:19:12.1150491Z",
"displayName": "Demo frontend app for Entra authentication",
"description": "This app is used to demonstrate a frontend application where a user authenticates using Entra ID authentication via OIDC",
"groupMembershipClaims": null,
"identifierUris": [],
"isDeviceOnlyAuthSupported": null,
"isDisabled": null,
"isFallbackPublicClient": false,
"nativeAuthenticationApisEnabled": null,
"notes": null,
"publisherDomain": "XXXXXXXX.onmicrosoft.com",
"serviceManagementReference": "business_unit1@jogcloud.com",
"signInAudience": "AzureADMultipleOrgs",
"tags": [],
"tokenEncryptionKeyId": null,
"uniqueName": null,
"samlMetadataUrl": null,
"defaultRedirectUri": null,
"certification": null,
"optionalClaims": null,
"servicePrincipalLockConfiguration": null,
"requestSignatureVerification": null,
"addIns": [],
"api": {
"acceptMappedClaims": null,
"knownClientApplications": [],
"requestedAccessTokenVersion": null,
"oauth2PermissionScopes": [],
"preAuthorizedApplications": []
},
"appRoles": [],
"info": {
"logoUrl": null,
"marketingUrl": null,
"privacyStatementUrl": null,
"supportUrl": null,
"termsOfServiceUrl": null
},
"keyCredentials": [],
"parentalControlSettings": {
"countriesBlockedForMinors": [],
"legalAgeGroupRule": "Allow"
},
"passwordCredentials": [],
"publicClient": {
"redirectUris": []
},
"requiredResourceAccess": [],
"verifiedPublisher": {
"displayName": null,
"verifiedPublisherId": null,
"addedDateTime": null
},
"web": {
"homePageUrl": null,
"logoutUrl": null,
"redirectUris": [
"http://localhost:8100/callback"
],
"implicitGrantSettings": {
"enableAccessTokenIssuance": false,
"enableIdTokenIssuance": false
},
"redirectUriSettings": [
{
"uri": "http://localhost:8100/callback",
"index": null
}
]
},
"spa": {
"redirectUris": []
}
}

Next up, I want to set an owner. Every application registration should have an owner and this is a child object of the application. Now don’t go willy-nilly throwing any business unit person into that field (the owner cannot be a group as of the date of this post). When a user is an owner of an application registration, they can modify the application registration. The owner should be set to some privileged user account in Entra where access to that privileged account is tightly controlled.

import requests
import json
# Set the owners of the application using their Entra ID user object id
owners = [
"2e69d9f2-b5b3-482b-9c15-XXXXXXXXXXXX"
]
........
# Add owners to the application registration
def add_owners_app_registration(owners: list, app_id: str):
"""This function adds owners to an application registration in Microsoft Graph API
Args:
owners (list): A list of Entra ID user object IDs to add as owners.
app_id (str): The object ID of the application registration to add owners to.
Returns:
list: The updated list of owners for the application registration.
"""
# Check the current owners to see if the owner is already listed
check_owners = get_app_registration_owner(app_id)
if check_owners is not None:
for owner in owners:
if owner in [o['id'] for o in check_owners]:
print(f"Owner {owner} is already an owner of the application.")
# Since owner isn't there, add it
else:
print(f"Adding owner {owner} to the application...")
response = requests.post(
f'https://graph.microsoft.com/v1.0/applications/{app_id}/owners/$ref',
headers={
'Content-Type': 'application/json',
'Authorization': f'Bearer {user_token.token}'
},
json={
"@odata.id": f"https://graph.microsoft.com/v1.0/directoryObjects/{owner}"
}
)
if response.status_code == 204:
print(f"Owner {owner} added successfully.")
else:
print(f"Failed to add owner {owner}. Response: {response.status_code} - {response.text}")
else:
print("No current owners found for the application.")
for owner in owners:
print(f"Adding owner {owner} to the application...")
response = requests.post(
f'https://graph.microsoft.com/v1.0/applications/{app_id}/owners/$ref',
headers={
'Content-Type': 'application/json',
'Authorization': f'Bearer {user_token.token}'
},
json={
"@odata.id": f"https://graph.microsoft.com/v1.0/directoryObjects/{owner}"
}
)
if response.status_code == 204:
print(f"Owner {owner} added successfully.")
else:
print(f"Failed to add owner {owner}. Response: {response.status_code} - {response.text}")
new_owners = get_app_registration_owner(app_id)
return new_owners
new_owners = add_owners_app_registration(owners = owners, app_id=app_frontend['id'])
print(json.dumps(new_owners, indent=2))

Next up I need to create a client credential for my application. This will act as its client_secret to support its confidential client status. Entra supports multiple types of credentials including a basic client secret, client certificate, and federated credential. Of the three, the federated credential is the sweet spot if you can make it work. This is where you can use something like a managed identity which means the actual secret is automatically managed and rotated by Microsoft. Way easier lifecycle. Federated credentials can also use external identity providers, like GCP, GitHub and others neat integrations via the workload identity federation. A client certificate should be your next preferred credential since it has higher assurance and avoids having to worry about secret rotation and leakage. Since I’m lazy, I’ll be using a client secret.

Below I create a client secret that will be valid for a year.

# Create a date one year from now that will be used to expire the app registration credential
start_date = datetime.now(timezone.utc)
end_date = (datetime.now(timezone.utc) + relativedelta(years=1)).replace(hour=23, minute=59, second=59, microsecond=0)
formatted_start_date = start_date.strftime('%Y-%m-%dT%H:%M:%SZ')
formatted_end_date = end_date.strftime('%Y-%m-%dT%H:%M:%SZ')
.........
# Create a client secret
def create_password_credential(app_id, end_date, start_date, override=False):
"""This function creates a password credential for an application registration in Microsoft Graph API. It will delete existing
credentials if override is set to True, otherwise it will return a message that a credential already exists.
Args:
app_id (str): The object ID of the application registration to create a password credential for.
end_date (str): The end date and time for the password credential in ISO 8601 format.
start_date (str): The start date and time for the password credential in ISO 8601 format.
override (bool): Whether to override existing password credentials. Defaults to False.
Returns:
dict: The deatils of the created password credential or a blank dict if a credential already exists and override is False.
"""
# Check to see if the app already has a password credential
app = get_app_registration(app_id)
if app['passwordCredentials'] == []:
# Create a new credential
body = {
"displayName": "primary",
"endDateTime": end_date,
"startDateTime": start_date
}
response = requests.post(
f'https://graph.microsoft.com/beta/applications/{app_id}/addPassword',
headers={
'Content-Type': 'application/json',
'Authorization': f'Bearer {user_token.token}'
},
json=body
)
if response.status_code != 200:
print(f"Error creating password credential: {response.status_code}: {response.text}")
else:
print("Created new password credential.")
return response.json()
elif override:
# Delete existing credentials
for cred in app['passwordCredentials']:
print("Deleting existing password credential...")
response = requests.post(
f'https://graph.microsoft.com/beta/applications/{app_id}/removePassword',
headers={
'Content-Type': 'application/json',
'Authorization': f'Bearer {user_token.token}'
},
json={
"keyId": cred['keyId']
}
)
if response.status_code != 204:
print(f"Error deleting password credential: {response.status_code}: {response.text}")
# Create a new credential
body = {
"displayName": "primary",
"endDateTime": end_date,
"startDateTime": start_date
}
response = requests.post(
f'https://graph.microsoft.com/beta/applications/{app_id}/addPassword',
headers={
'Content-Type': 'application/json',
'Authorization': f'Bearer {user_token.token}'
},
json=body
)
if response.status_code != 200:
print(f"Error creating password credential: {response.status_code}: {response.text}")
else:
print("Created new password credential after deleting existing one.")
return response.json()
else:
print("A secret already exists. You can delete it and create a new one by setting override=True")
return app['passwordCredentials'][0]
password_credential_frontend = create_password_credential(app_frontend['id'], formatted_end_date, formatted_start_date, override=False)

Alright, at this point we have an application registration and client credential, which essentially means we have manually registered the application as an OAuth client to the authorization server (Entra ID). I now have a client_id (appId property) and client_secret. What next?

Creating the frontend service principal

I now need a security principal (or identity) to represent my application in my Entra ID tenant. In comes the service principal. There are many types of service principals as I mentioned previously, for this use case I’ll be creating an application service principal. Manual creation of this is only required because I’m creating it programmatically through REST. If I created this app registration in Azure Portal a service principal would automatically be created.

Creating the service principal is very straightforward and there’s not much need you to pass beyond the appId (or client id) of the application registration.

def create_service_principal(app_id: str):
"""This function creates a service principal for an application registration in Microsoft Graph API if it doesn't already exist
Args:
app_id (str): The application ID of the service principal to create.
Returns:
dict or None: The details of the created service principal if successful, otherwise None.
"""
# Check to see if the service principal already exists
service_principal = get_service_principal_by_app_id(app_id)
if service_principal is not None:
print(f"Service principal already exists: {service_principal['id']}")
return service_principal
else:
body = {
"appId": app_id
}
response = requests.post(
'https://graph.microsoft.com/v1.0/servicePrincipals',
headers={
'Content-Type': 'application/json',
'Authorization': f'Bearer {user_token.token}'
},
json=body
)
if response.status_code == 201:
return response.json()
else:
print(f"Error creating service principal: {response.status_code}: {response.text}")
return None
# Get or create the service principal
service_principal_frontend = create_service_principal(app_frontend['appId'])
print(json.dumps(service_principal_frontend, indent=2))

This spits out a new service principal object seen below. You’ll notice the schema is somewhat similar to the application registration schema. The service principal will be the object the application uses to exercise permissions it is delegated across the platform.

{
"@odata.context": "https://graph.microsoft.com/v1.0/$metadata#servicePrincipals/$entity",
"id": "ce341fd2-fd6b-4dab-9beb-XXXXXXXXXXXX",
"deletedDateTime": null,
"accountEnabled": true,
"alternativeNames": [],
"appDisplayName": "Demo frontend app for Entra authentication",
"appDescription": "This app is used to demonstrate a frontend application where a user authenticates using Entra ID authentication via OIDC",
"appId": "fc815c55-d456-4d38-be76-XXXXXXXXXXX",
"applicationTemplateId": null,
"appOwnerOrganizationId": "6c80de31-d5e4-4029-93e4-XXXXXXXXXXXX",
"appRoleAssignmentRequired": false,
"createdByAppId": "04b07795-8ddb-461a-bbee-XXXXXXXXXXXX",
"createdDateTime": "2026-06-25T01:41:05Z",
"description": null,
"disabledByMicrosoftStatus": null,
"displayName": "Demo frontend app for Entra authentication",
"homepage": null,
"isDisabled": null,
"loginUrl": null,
"logoutUrl": null,
"notes": null,
"notificationEmailAddresses": [],
"preferredSingleSignOnMode": null,
"preferredTokenSigningKeyThumbprint": null,
"replyUrls": [
"http://localhost:8100/callback"
],
"servicePrincipalNames": [
"fc815c55-d456-4d38-be76-XXXXXXXXXXX
],
"servicePrincipalType": "Application",
"signInAudience": "AzureADMultipleOrgs",
"tags": [],
"tokenEncryptionKeyId": null,
"samlSingleSignOnSettings": null,
"addIns": [],
"appRoles": [],
"info": {
"logoUrl": null,
"marketingUrl": null,
"privacyStatementUrl": null,
"supportUrl": null,
"termsOfServiceUrl": null
},
"keyCredentials": [],
"oauth2PermissionScopes": [],
"passwordCredentials": [],
"resourceSpecificApplicationPermissions": [],
"verifiedPublisher": {
"displayName": null,
"verifiedPublisherId": null,
"addedDateTime": null
}
}

Document your required permissions!

It’s best practice to document the permission your application will require versus being an asshole and forcing someone to guess, struggle, learn to hate you, and likely over permission. Permissions are divided into two categories which include role permissions and scope permissions. Role permissions are going to be the permissions the app exercises using its own identity context (we’ll see some of this in a future post) and scope permissions are going to be delegated permissions it requires. The permissions an application requires can be documented as part of the app registration by setting the requiredResourceAccess property of the application registration. This doesn’t grant any access, but simply informs the administrator what permissions will be required from the application. Remember, the app registration is the template for the application.

# Get the existing app permissions
def get_app_permissions(id: str):
"""This function retrieves the existing permissions required for an application registration from the Microsoft Graph API.
Args:
id (str): The object ID of the application registration to retrieve permissions for.
Returns:
list: A list of required resource permissions or else an empty list
"""
response = requests.get(
f'https://graph.microsoft.com/v1.0/applications/{id}',
headers={
'Content-Type': 'application/json',
'Authorization': f'Bearer {user_token.token}'
}
)
if response.status_code != 200:
print(f"Error getting app permissions: {response.status_code}: {response.text}")
return None
# Return the current permission set or a blank array if there are no permissions currently set
return response.json().get('requiredResourceAccess') or []
# Add the app permissions to the app registration. This is useful for multi-tenant apps to document required permissions. It does not grant any permissions.
def add_app_permissions(app_id: str, resource_access: list):
"""This function adds the required permissions to an application registration in Microsoft Graph API
Args:
app_id (str): The object ID of the application registration to add permissions to.
resource_access (list): A list of permissions to add to the application registration in the format of requiredResourceAccess.
Returns:
dict or None: The updated application registration details if successful, otherwise None.
"""
# Get the existing permissions in order to append to them
app_permissions = get_app_permissions(app_id)
# Append the new permissions to the existing ones
for permission in resource_access:
# Check if this resource already exists in the app permissions
existing_resource = None
for resource in app_permissions:
if resource['resourceAppId'] == permission['resourceAppId']:
existing_resource = resource
break
if existing_resource:
# Append the new permissions to the existing resource
for access in permission['resourceAccess']:
if access not in existing_resource['resourceAccess']:
existing_resource['resourceAccess'].append(access)
else:
# Add the new resource and its permissions
app_permissions.append(permission)
# Update the app registration with the new permissions
body = {
"requiredResourceAccess": app_permissions
}
response = requests.patch(
f'https://graph.microsoft.com/v1.0/applications/{app_id}',
headers={
'Content-Type': 'application/json',
'Authorization': f'Bearer {user_token.token}'
},
json=body
)
if response.status_code != 204:
print(f"Error adding app permissions: {response.status_code}: {response.text}")
return None
else:
print("App permissions documented as required successfully.")
return get_app_registration(app_id)
new_permissions = [
{
"resourceAppId": "00000003-0000-0000-c000-000000000000", # Microsoft Graph
"resourceAccess": [
{
"id": "e1fe6dd8-ba31-4d61-89e7-88639da4683d", # User.Read
"type": "Scope"
}
]
}
]
app_required_permissions = add_app_permissions(app_frontend['id'], new_permissions)['requiredResourceAccess']
print("\n=== App required permissions ===")
print(json.dumps(app_required_permissions, indent=2))

Once those are added they will appear in the API permissions section of the Application Registration inside of the Azure Portal as seen below. For my app I’m documenting that it requires the User.Read delegated permission on the Microsoft Graph API. For some of the built-in applications like the Microsoft Graph, some permissions will require admin consent and some will not like User.Read. If you add these things programmatically, it’s a bit more work because you need to dig up the resource’s appId and object ids of the permission. Something like Microsoft Graph is well documented.

Alright, at this point we’re ready to test our app!

Authenticating to the application

After starting the application I immediately see a lookup to metadata endpoint for the OIDC and OAuth endpoints. This is triggered an MSAL instance is started in the code. These endpoints will be used throughout the login process.

Opening my website I’m faced with a very simple login screen (simple setup for a simple man).

Once I click login with Entra ID, the underlining MSAL library redirects me to the /authorize endpoint of Entra where my user is prompted to authenticate. The request that is generated is below. In this request we see all the things we covered in the second post for the protocol primer. There is the redirect URI that the user will be redirected to after authenticating to Entra, the response_type indicating this is the authorization code grant type, the client id of my application, the state property used to mitigate CSRF attacks, the nonce to prevent replay attacks, and the code challenge and code challenge method for PKCE.

Now one thing to note is you’ll find a lot of samples on the wider Internet for MSAL (and likely generated by LLMs if you’re one of those vibe coders) that will use the acquire_token_for_client method (like this Microsoft sample here). This method WILL NOT use PKCE. If you want to include the code challenge and code verifier for PKCE you will need to use the initiate_auth_code_flow method.

The scopes query perimeter includes the Microsoft Graph User.Read permission, offline_access (for a refresh token), openid (for an id token), and profile (for access to the user’s basic profile for OIDC). The code in my app specifically requests User.Read, the remaining scopes are automatically added by MSAL for each request depending on the method you’re calling.

https://login.microsoftonline.com/6c80de31-d5e4-4029-93e4-XXXXXXXXXXXX/oauth2/v2.0/authorize?
client_id=afbd7539-a21f-4d11-93a3-XXXXXXXXXXXX&
response_type=code&
redirect_uri=http%3A%2F%2Flocalhost%3A8100%2Fcallback&
scope=User.Read+offline_access+openid+profile&
state=iuxzJhtpdQrWHKqG&code_challenge=L2KNF971_Izy0wWY4v_8GJ1XXXXXXXXXXXX&
code_challenge_method=S256&
nonce=b448a1420a781ac5f18bc2db7f74e06a42fbedca3dd04ebdXXXXXXXXXXXX&
client_info=1

My user completes the authentication process the user is prompted to consent to the application to be delegated the requested scopes. Once the user accepts, the user’s consent is saved in Entra and the user is no longer required to consent moving forward. You’ll notice my application says app is unverified because it’s using localhost. For anything remotely relating to production, you should configure a publishing domain and validate it.

Once the user consents, the user is redirected to the redirect uri registered for the application with an authorization code generated by Entra. My application then makes a call to the /oauth2/v2.0/token endpoint in Entra to exchange the authorization code for an access token, identity token, and refresh token. It provides its client secret to authenticate itself to Entra and the code_verifier value allowing Entra to validate this is the original client who requested the access token (PKCE).

Entra validates the client secret and code verifier and if valid returns an access token, refresh token, and id token. My application can use the id token to authenticate the user and grant it access to the application.

Once logged in, I navigate to the profile page of the application. This page has basic profile information about the user collected from the get user endpoint in the Microsoft Graph.

Navigating to the tokens page of the application displays the decoded access token and id token. In the payload of the id token we can see this id token is intended for the application (which you must validate in your code to ensure someone isn’t trying to pass you some rando token meant for another application) via the aud claim. We also get some basic information about the user. The full schema of the id token is in the official public docs. Some of the helpful properties are the user’s full name and their object id (oid). The object id could be used to pull additional information about the user (which we’ll see next post). We can also stuff additional claims in this id token if we wanted to. I’ll demonstrate this in a future post where I add a user’s group memberships into the id token.

{
"aud": "fc815c55-d456-4d38-be76-XXXXXXXXXXXX",
"exp": 1782358405,
"iat": 1782354505,
"iss": "https://login.microsoftonline.com/6c80de31-d5e4-4029-93e4-XXXXXXXXXXXX/v2.0",
"name": "Carl Carlson",
"nbf": 1782354505,
"nonce": "19297055204c96a487b701f62890cf1c867a7fac55814081f53cbe4XXXXXXXX",
"oid": "2e69d9f2-b5b3-482b-9c15-XXXXXXXXXXXX",
"preferred_username": "carl.carlson@jogcloud.com",
"rh": "1.AbcAMd6AbOTVKUCT5ForPA4SmVVcgfxW1XXXXXXXXXXXX",
"sid": "005f65fa-bad8-71a5-49eb-XXXXXXXXXXXX",
"sub": "p9RBIgpi113pdPH37Q50qylIbANwgMtDXXXXXXXXXXXX",
"tid": "6c80de31-d5e4-4029-93e4-XXXXXXXXXXXX",
"uti": "tL2-eu4OB0KFhXGl7j4UAA",
"ver": "2.0"
}

The end-to-end flow

So I’ve authenticated my user to the application using OIDC and gained delegated access to the Microsoft Graph API via OAuth all using Entra. Not too shabby. This is the most basic of basic use cases. In my next post I’ll walk you through how to add group information to the id or access token which you could use within your application to authorize the user within the application.

I’m a big fan of old school style protocol flow diagrams, so I threw one together that walks through the end-to-end process I’ve outlined today.

Summing It Up

Yeah, I know that was a lot. If all you take out of this post is a better understanding of what app registrations and service principals do and have a general understanding of how they’re structured, and what the protocol flow looks like when using Entra ID for OIDC/OAuth, that’s a win.

If you want to muck around with this stuff yourself in a personal or test tenant, I’ve published all the code I put together to run through these posts in this repository. It’s a work in progress which I’m fine-tuning as I write these posts but it does have the sample frontend app included in it if you want to take a glance at my application-level code and perhaps want to replicate what I walked through today. Please do not use any of this code in a production app. This is purely intended to demonstrate the concepts.

Some key takeaways for you:

  1. Every app registration should have an owner. Just be aware the owner can modify the app registration so don’t go nuts and give this to a non-privileged user.
  2. Set the servericeManagementReference property to some type of BU-level distribution list. This will cover you in case the owners are wiped out through someone accidentally removing them or them leaving the company.
  3. Make sure you’re using the correct methods in the MSAL library if your goal is to use PKCE to align with OAuth 2.1.
  4. If you setup an app registration, be a good human being and document the permissions the app is going to require.
  5. When configuring an app registration that will be a confidential client, try to use a federated identity credential. If your app is running in Azure, you can use a managed identity. This will both be more secure and make your app owner’s life a little less miserable having to rotate credentials.

See you next post!

Entra ID – Deep Dive – The Basics – Part 1

Entra ID – Deep Dive – The Basics – Part 1

This is part of my series on Microsoft Entra ID:

  1. Entra ID – Deep Dive – The Basics – Part 1
  2. Entra ID – Deep Dive – Protocol Primer – Part 2
  3. Entra ID – Deep Dive – Entra ID Authentication – Part 3

Yeah… You read that right. I’m finally biting the bullet and doing a series on Entra ID. There are some cobwebs there, but once upon a time I was a half-baked “identity guy”. Nah, I’m not returning to that place, but I have had to visit it more frequently over the past few months. With the growing use of agents, identity has one again become of those things that apparently everyone is a so called “expert in”. I aint no expert, but I have been digging into the chatter around the implications of agents into the identity world. Given my employer, that has been spending some time in the Entra ID Agent Identity space.

Digging into that demanded I go back to some of the basics of Entra ID such as the purpose of an application registration versus a service principal and the request flow when authenticating a user to an application using OIDC (OpenID Connect) or executing an on-behalf-of flow in OAuth. I had conceptual knowledge of the above, but it was too ivory tower. I needed to eat the dog food and do it. After spending a few weeks reading through the documentation, reviewing captured requests and responses, reviewing RFCs, and poorly coding some applications to exercise on the concepts I figured it was time to brain dump what I learned before I get distracted with some new shiny widget and forget 60% of it.

So yeah… that’s where this series is coming from. Hopefully some folks out there draw some value out of it beyond serving as a refresher for my neural pathways.

With that rambling out of the way, let’s get to it.

WTF is Entra ID?

Many years ago when Microsoft first introduced Azure Active Directory my peers and I scratched our heads with this question. Given the name, the initial assumption was it was the Windows Active Directory “killer” (stop trying to make that happen, it aint gonna happen). That seems to have been a pretty common assumption given what I’ve heard from customers over the years as I sat in the vendor space and is likely why the name was shifted a few years back to Entra ID. Either that or marketing needed to justify their existence with another product rename.

If you want the professional explanation as to what Entra ID is you can read the public documentation and bask in the marketing mumbo jumbo. Since you’re here, you’re going to get the less fancy and quick and dirty Matt Felton explanation. My take is that Entra ID does a lot of shit, but at its core it is:

  • An identity store for the identities of human and non-human security principals, their attributes, and their credentials.
  • An authentication service which can act as an OAuth Authorization Server, OIDC identity provider, SAML IdP / SP, and even Kerberos / LDAP provider (gross)

It provides these core services to Microsoft’s cloud services such as M365, Azure, Dynamics 365, and whatever other clouds Microsoft has that I’m missing. It can be extended to provide these services to other applications by integrating with it via one of the supported protocols like we’ll see in further posts in this series.

Entra ID is divided into separate tenants which represent unique identity boundaries. Services for a given customer (such as an Azure subscription) are associated to an Entra ID tenant which acts as the identity boundary for those resources. Tenants can trust other tenants to facilitate cross tenant accessing of resources through features like Entra ID External ID.

The identity store piece of the Entra ID service is where users, groups, many types of service principals, applications, and device identities are stored. Similar to an LDAP (hell may be LDAP under the hood, who the hell knows) each of these objects has a schema with specific properties that are consumed for the other services Entra provides (as we’ll see later).

That should be enough context to give you a very general idea of what Entra ID is. Like I mentioned above, it does a lot of shit (conditional access, privileged identity management, etc) that isn’t relevant to the point of this series and that I’m not going to discuss. If you can hammer it in your head those two core functions, it will be enough to get you through this series.

Humans vs Non-Humans

Within Entra ID we have two primary objects that represent humans and non-humans. For humans we have the user identity. For non-humans we have service principals. Service principals come in many flavors including the base service principal (consider legacy), applications, managed identities, Agent Identity Blueprints, and Agent Identities (likely others I’m missing), Agent Users. The way I like to think about the many types of service principals (probably incorrect but I don’t care) is that the base service principal is the parent object class and things like managed identities, agent identity blueprints, and agent identities are children of that object class which look a bit different. Agent Users are a bit weird and I haven’t delved into them much, however, I like to think of it as a child of the base user object class.

The other object type that’s important to understand is the application object (typically referred to as the app registration). The application object is interesting (and can be confusing). It exists to represent a globally unique representation of an application. For a given application, you only ever have a single application object which exists in the tenant it was registered. What’s helped me is to think of the application object as the OAuth client registration. That’s probably not completely correct, but it helps ground you in its purpose. The application object can have a credential (secret, certificate, federated) which allows it to authenticate to Entra ID (think OAuth confidential client). It also contains information critical to OAuth such as the client id (appid), the audience (identifierUris), the OAuth scopes it supports (oauth2PermissionScopes), and redirectUris (when using interactive OAuth flows).

Below you’ll see an example of an application object for an application that is acting as a backend API to the demo solution I put together for this series.

Application Demo backend app for Entra authentication already exists and its id is 23f7d0f0-85e6-453a-b0bb-723b65ac7958
{
"id": "23f7d0f0-85e6-453a-b0bb-723b65ac7958",
"deletedDateTime": null,
"appId": "22d2ff53-9442-404c-8da5-01c2e135532d",
"applicationTemplateId": null,
"disabledByMicrosoftStatus": null,
"createdByAppId": "04b07795-8ddb-461a-bbee-02f9e1bf7b46",
"createdDateTime": "2026-06-08T23:46:22Z",
"displayName": "Demo backend app for Entra authentication",
"description": "This app is used to demonstrate a backend API that uses the user's identity context to fetch a user's story'",
"groupMembershipClaims": null,
"identifierUris": [
"api://22d2ff53-9442-404c-8da5-01c2e135532d"
],
"isDeviceOnlyAuthSupported": null,
"isDisabled": null,
"isFallbackPublicClient": false,
"nativeAuthenticationApisEnabled": null,
"notes": null,
"publisherDomain": "XXXXXXXX.onmicrosoft.com",
"serviceManagementReference": null,
"signInAudience": "AzureADMultipleOrgs",
"tags": [],
"tokenEncryptionKeyId": null,
"uniqueName": null,
"samlMetadataUrl": null,
"defaultRedirectUri": null,
"certification": null,
"optionalClaims": null,
"servicePrincipalLockConfiguration": null,
"requestSignatureVerification": null,
"addIns": [],
"api": {
"acceptMappedClaims": null,
"knownClientApplications": [],
"requestedAccessTokenVersion": 2,
"oauth2PermissionScopes": [
{
"adminConsentDescription": "Allow the application to impersonate the signed-in user to access their user story.",
"adminConsentDisplayName": "Impersonate user",
"id": "00000000-0000-0000-0000-000000000001",
"isEnabled": true,
"type": "User",
"userConsentDescription": "Allow the application to impersonate you to access your user story.",
"userConsentDisplayName": "Impersonate user",
"value": "user_impersonation"
}
],
"preAuthorizedApplications": []
},
"appRoles": [],
"info": {
"logoUrl": null,
"marketingUrl": null,
"privacyStatementUrl": null,
"supportUrl": null,
"termsOfServiceUrl": null
},
"keyCredentials": [],
"parentalControlSettings": {
"countriesBlockedForMinors": [],
"legalAgeGroupRule": "Allow"
},
"passwordCredentials": [
{
"customKeyIdentifier": null,
"displayName": null,
"endDateTime": "2028-06-08T23:46:45.7547438Z",
"hint": "NnW",
"keyId": "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXX",
"secretText": null,
"startDateTime": "2026-06-08T23:46:45.7547438Z"
}
],
"publicClient": {
"redirectUris": []
},
"requiredResourceAccess": [
{
"resourceAppId": "e406a681-f3d4-42a8-90b6-c2b029497af1",
"resourceAccess": [
{
"id": "03e0da56-190b-40ad-a80c-ea378c433f7f",
"type": "Scope"
}
]
}
],
"verifiedPublisher": {
"displayName": null,
"verifiedPublisherId": null,
"addedDateTime": null
},
"web": {
"homePageUrl": null,
"logoutUrl": null,
"redirectUris": [],
"implicitGrantSettings": {
"enableAccessTokenIssuance": false,
"enableIdTokenIssuance": false
},
"redirectUriSettings": []
},
"spa": {
"redirectUris": []
}
}

Now comes the importance of the service principal. An application object will typically have an associated service principal (not much use without it) which acts as the security principal for the application in the given Entra ID tenant. Applications have one application object (or app registration) but many service principals (if it’s multi-tenant). Think of the service principal as the “stub” identity representing the application in the Entra ID tenant. Permissions are granted to the service principal and the application uses the service principal to exercise those permissions to do things such as querying the Microsoft Graph API.

If you build a multi-tenant application like I’ve done here, other Entra ID tenants can create a service principal for the application in their tenant allowing the application to possibly authenticate those users and access resources in that Entra ID tenant.

The official documentation likes to refer to the application object as the template for the application and the service principal as the security principal. I think that’s a pretty damn good single sentence explanation.

Below is an example of the service principal for the frontend application I built for this series. You can see the type of service principal (servicePrincipalType) in this scenario is application because this service principal has an associated application object (app registration).

{
"id": "ece073e5-744e-4d70-b79d-4887e1dd008f",
"deletedDateTime": null,
"accountEnabled": true,
"alternativeNames": [],
"appDisplayName": "Demo frontend app for Entra authentication",
"appDescription": "This app is used to demonstrate a frontend application where a user authenticates using Entra ID authentication via OIDC",
"appId": "afbd7539-a21f-4d11-93a3-490017032fb7",
"applicationTemplateId": null,
"appOwnerOrganizationId": "6c80de31-d5e4-4029-93e4-5a2b3c0e1299",
"appRoleAssignmentRequired": false,
"createdByAppId": "04b07795-8ddb-461a-bbee-02f9e1bf7b46",
"createdDateTime": "2026-06-08T23:45:54Z",
"description": null,
"disabledByMicrosoftStatus": null,
"displayName": "Demo frontend app for Entra authentication",
"homepage": null,
"isDisabled": null,
"loginUrl": null,
"logoutUrl": null,
"notes": null,
"notificationEmailAddresses": [],
"preferredSingleSignOnMode": null,
"preferredTokenSigningKeyThumbprint": null,
"replyUrls": [
"http://localhost:8100/callback"
],
"servicePrincipalNames": [
"afbd7539-a21f-4d11-93a3-490017032fb7"
],
"servicePrincipalType": "Application",
"signInAudience": "AzureADMultipleOrgs",
"tags": [],
"tokenEncryptionKeyId": null,
"samlSingleSignOnSettings": null,
"addIns": [],
"appRoles": [],
"info": {
"logoUrl": null,
"marketingUrl": null,
"privacyStatementUrl": null,
"supportUrl": null,
"termsOfServiceUrl": null
},
"keyCredentials": [],
"oauth2PermissionScopes": [],
"passwordCredentials": [],
"resourceSpecificApplicationPermissions": [],
"verifiedPublisher": {
"displayName": null,
"verifiedPublisherId": null,
"addedDateTime": null
}
}

What are we going to build?

Now that you have the bare bones basics of Entra, you likely want to understand how an application would go about using it for authentication and authorization. While you might not be doing this now and it may not seem relevant, it will become very relevant to you if you begin building agents in Microsoft’s clouds through Microsoft Foundry, CoPilot Studio, or the 18 other random services Microsoft allows agents to be built. This will also be relevant to you if you’re going to consume Microsoft resources (such as Azure) from other clouds through an application or an agent. So yeah, you should understand what this looks like to do. The whole Entra ID Agent Identity feature builds on these foundational pieces.

To see these concepts in action I’m going to walk through a very simplistic solution with a frontend website and backend API in Python. The frontend website is built using the Flask Framework and the backend API is built with fastapi.

Demo application architecture

The frontend website will use Entra ID to authenticate the user and will be issued an OIDC id token to identify the user. The frontend will get some information from the user from the id token and access token it receives from Entra and will make additional calls to the Microsoft Graph API using the client credentials flow to grab other attributes of the user’s identity. I’ll also show how to include the user’s Entra ID group information in the id or access token and how to handle nested group membership.

The frontend will have a link on it to call the /story endpoint of the backend API. The story endpoint will pull a pre-built AI generated story about the user which has been uploaded toblob storage in an Azure Storage Account. The backend API will use the on-behalf-of flow to access the storage account as the user to pull the user’s specific story.

This will demonstrate some of the most common flows including authentication, OAuth client credentials flow, and OAuth on-behalf-of (or jwt bearer). I’ll show you how the id tokens and access tokens look in different scenarios pointing out the relevant claims and how they’re used upstream. I’ll even share some process flows so you understand what does what in a given flow.

My primary goal here is give you the basics so you can walk away with a more solid understanding of what’s happening under the hood and how Entra ID has decided to implement OIDC and OAuth. I can’t stress how helpful this will be for you once you start diving into the agent identity space (which I’ll be covering after this series).

Summing it up

Ok, so you know what you’re in for. This series is gonna be relatively deep in the weeds so bring your favorite caffeinated beverage for future posts. I’m doing everything direct with the REST APIs because I want to show you the gory details. No pretty SDKs for you. If you’ve had a “conceptual” idea of how this works without the implementation specifics (like I did before I went down this rabbit hole) this series should help to fill those gaps.

In the next post I’ll walk through setting up Entra for the frontend website, authenticating a user, and exploring the id token and access token. The post following that will walk through using the application’s identity context to get more information about the user from the Microsoft Graph API such as nested group membership, then I’ll finish up the series by walking through the on-behalf-of flow with the backend API to show you how to carry the user’s identity context through the application to the destination resource down the line.

See you next post!

Microsoft Foundry – Publishing Agents To Teams Deep Dive – Part 2

Microsoft Foundry – Publishing Agents To Teams Deep Dive – Part 2

This is part of my series on Microsoft Foundry:

  1. Microsoft Foundry’s Evolution
  2. Microsoft Foundry BYO AI Gateway (BYO Model) – Part 1
  3. Microsoft Foundry BYO AI Gateway (BYO Model) – Part 2
  4. Microsoft Foundry BYO AI Gateway (BYO Model) – Part 3
  5. Microsoft Foundry Publishing Foundry Agents to Microsoft Teams – Part 1
  6. Microsoft Foundry Publishing Foundry Agents to Microsoft Teams – Part 2

With Memorial Day weekend coming quickly, I wanted to get the second post to this series out before the knowledge my late nights with Red Eye coffee brought leaks from my brain. In my last post I did a walkthrough of the Publishing Agents To Teams feature of the Foundry Agent Service within Microsoft Foundry. In that post I covered the Portal experience, broke open some of the black box as to my understanding of the workflow that happens underneath when you push the publish button, and talked through the AI Bot Service’s role in the feature. For this post I’m going to cover a possible network architecture to support this feature when security controls are required around inbound and outbound network access (I mentioned a few last post), the network flow for that architecture, and some of the switches and knobs you can turn to add additional security beyond the basic layer 4 network controls. After that, I’m going to walk through a Jupyter Notebook I put together than shows you how to perform the steps behind the publish button programmatically. If you haven’t read my last post, Graeme’s blog post on this topic, and Moim’s blog post on reverse engineering Bot services you should do that before you try to tackle this one.

A Possible Architecture

As I covered in my last post, when we want to make an agent available in Teams we need Teams to be capable of reaching it. In this design, with Teams interacting with the AI Bot Service which relays the information to our agent, this means we need to make the agent’s messaging endpoint available to the Microsoft public backbone (i.e. it needs to be exposed via a public IP address). Graeme provided one architecture to accomplish this which will work for a number of folks. I foresee a few different architectural options:

  • APIM v2 configured for public inbound and regional vnet integration
  • APIM classic configured for external mode
  • App Gateway with a public listener with APIM v2 VNet Injected or PE + regional vnet integration behind it
  • App Gateway with a public listener with APIM classic VNet injection behind it
  • Firewall DNAT + APIM v2 VNet Injected or PE + regional vnet integration behind it
  • Firewall DNAT + APIM classic VNet injection behind it

For this post I’m going to focus on the 3rd option which has Application Gateway sitting in front of an v2 tier API Management. I like this pattern because I get the WAF, SNI, host-based routing, and path-based routing benefits of an App Gw (Application Gateway) and avoid slapping a public IP on my APIM (API Management). There is more complexity to this pattern, but more security and flexibility always comes with more complexity, right?

Generally my traffic will look something like the image you seen down below.

The green line is the incoming message from the Microsoft Teams. We see it is relayed from the Teams Service to the public IP address of the AppGw via the Bot Service. From there, we send it through the APIM and finally on to the Private Endpoint for Foundry which tunnels it on to the Microsoft-managed compute behind the Foundry Agent Service.

The blue line is the response from the agent. You’ll notice there are two blue lines. Based on the logs in my firewall when I tested this, I did not see the response traffic back to the Bot Service (this would be the endpoint in the serviceurl in the JWT received from the Bot Service which should be something like smba.trafficmanager.net). I’m making the assumption that this traffic isn’t egressed through the customer virtual network and instead flows out whatever path Microsoft is providing in the network where the managed compute lives that hosts the agent runtimes. Additionally, you’ll notice a blue line flowing through my virtual network and headed to an FQDN at tenant.api.powerplatform.com. I’m still trying to get clarification on if this flow is truly required and what it’s for.

The first instinct of us old networking farts is to look at this diagram and think this is asymmetric routing. However, in this situation it isn’t because the green and blue flows are separate TCP sessions because the message and response sequence is asynchronous.

Execution of the Architecture

Alright, you now have an understanding of the flow with this architecture. Let’s talk about the cool shit we can do with it. I’ve set the messaging endpoint in my Bot Service resource to https://agent.agw.jogcloud.com/agents/api/projects/sampleproject1/agents/test-manual-publish/endpoint/protocols/activityProtocol?api-version=2025-05-15-preview. What I’ve done is replace my FQDN with my AppGw’s FQDN and I appended /agents after the FQDN to ensure it routes to the proper API on my APIM.

Given we’re starting with AppGw we can use the WAF functionality to validate the source IP address is coming from the Teams service. A simple rule like the below will do that check.

Next, I want to validate the request header of x-ms-tenant-id to validate that the header is present and contains my tenant id.

Next up I have APIM. Here I’ve created an API with an operation named PublishedAgent. The operation is defined as you see below.

Within the operation, I’ve taken Graeme’s policy and made a small tweak to it to validate the serviceurl claim in the JWT and ensure it contains my tenant id.

<policies>
<inbound>
<base />
<validate-jwt header-name="Authorization" require-scheme="Bearer">
<openid-config url="https://login.botframework.com/v1/.well-known/openidconfiguration" />
<audiences>
<audience>8fd8ec07-ae24-4038-8771-6d4b85a4b19a</audience>
</audiences>
<issuers>
<issuer>https://api.botframework.com</issuer>
</issuers>
<required-claims>
<claim name="serviceurl" match="all">
<value>https://smba.trafficmanager.net/amer/6c80de31-d5e4-4029-93e4-5a2b3c0e1299/</value>
</claim>
</required-claims>
</validate-jwt>
</inbound>
<backend>
<base />
</backend>
<outbound>
<base />
</outbound>
<on-error>
<base />
</on-error>
</policies>

If we bring it back up out of the weeds and to the high level, here is what we’re doing at each component in the flow.

So there you have it folks, that’s an architecture you could use and some of the details of getting it up and running. Now let’s bounce over and take a look at how to avoid the manual action of “pushing the pretty blue button” and look at how we’d publish a Foundry Agent programmatically.

Programmatic Setup

The kind folks over at the Foundry Agents PG (product group) put together a sample of the steps needed to do this programmatically with PowerShell and Bicep. Since I prefer good ole bash shell, Python, and Terraform I reworked their steps into a Jupyter Notebook which you can find here. There is a sample env file in the repository. You don’t need to populate the client id and client secret unless you want to play around with the commands in the appendix. Those are not required.

The first step in the process is creation of the Bot Service resource in Azure. As I covered in my last post, this resource mainly exists to store metadata about your bot (or agent in this scenario) that the AI Bot Service uses to relay data back and forth between Teams and the agent. You’ll want to create a new Bot Service which will require you have the specific permissions to do that (if you want to go the custom role) or something more generic like Contributor. You’ll also want to make sure the Bot.Service resource provider is registered in your subscription (pretty sure this requires Owner).

I’ve crafted a Terraform template for this step. Before you can create the Bot Service with the template, you need to collect some Entra ID-related information. First, you’ll need to fetch your Entra ID tenant ID. You can do this programmatically by running after logging into az cli using the command below.

az account show --query tenantId -o tsv

Now that you have you’re logged into az cli and you’ve grabbed the tenant id, your next step is to fetch the principal id (or appId) of the Entra ID Agent Identity associated with the Foundry Agent. You’ll associate this identity with the Bot Service resource. Before you do that, you’ll need to get fetch an access token with the appropriate scope.


from azure.identity import DefaultAzureCredential
from dotenv import load_dotenv

# Get a token for Foundry scope
credential = DefaultAzureCredential()
scopes = ["https://ai.azure.com"]

user_token = credential.get_token(*scopes)



Next you can use this function to grab the principal_id property.

import os
import json
import requests
from dotenv import load_dotenv
# Load environmental variables
load_dotenv(override=True)
# Function that gets the agent object
def get_foundry_agent(account_name: str, project_name: str, agent_name: str, token: str):
"""This function retrieves a Foundry agent by name from a Foundry project
Args:
account_name (str): The name of the Foundry account
project_name (str): The name of the Foundry project
agent_name (str): The name of the Foundry agent to retrieve
token (str): The authentication token to use for the API request
Returns:
dict: The Foundry agent details if found, otherwise None
"""
response = requests.get(
f"https://{account_name}.services.ai.azure.com/api/projects/{project_name}/agents/{agent_name}?api-version=v1",
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {token}"
}
)
if response.status_code == 200:
return response.json()
else:
logging.error(f"Failed to retrieve agent: {response.status_code} - {response.text}")
return None
# Grab the principal_id of the Entra ID Agent Identity associated with the Foundry Agent
foundry_account_name = os.getenv("FOUNDRY_ACCOUNT_NAME")
project_name = os.getenv("FOUNDRY_PROJECT_NAME")
agent_name = os.getenv("FOUNDRY_AGENT_NAME")
agent = get_foundry_agent(foundry_account_name, project_name, agent_name, user_token.token)
agent_principal_id = agent.get("instance_identity", {}).get("principal_id")
print(f"Foundry Agent Principal ID: {agent_principal_id}")
print(json.dumps(agent, indent=2))

Once you have the tenant id and principal id of the agent identity associated with your Foundry Agent, you are almost ready to create the Bot Service. The last step is formulating your messaging endpoint. It will look something like this:

https://FOUNDRY_ACCOUNT_NAME.services.ai.azure.com/api/projects/PROJECT_NAME/agents/AGENT_NAME/endpoint/protocols/activityProtocol?api-version=2025-05-15-preview

As I showed earlier, you can modify this to change the FQDN to point to your preferred ingress infrastructure and add pathing to the beginning to ensure proper routing through an API Gateway.

Now that you have everything ready to go you can run a Terraform template like the one located here. This will create the Bot Service and Teams channel child object and configure diagnostic settings with delivery to the specified (Log Analytics Workspace).

Once that is complete, you need enable the activity protocol support for your agent. You can do this using the code below:

import os
import json
import requests
from dotenv import load_dotenv
# Load environmental variables
load_dotenv(override=True)
# Function that enables the activity protocol for the agent and configures the required Bot Service authorization scheme
def enable_agent_activity_protocol(account_name: str, project_name: str, agent_name: str, token: str):
"""This function enables the activity protocol for a Foundry agent and configures the required Bot Service authorization scheme
Args:
account_name (str): The name of the Foundry account
project_name (str): The name of the Foundry project
agent_name (str): The name of the Foundry agent to retrieve
token (str): The authentication token to use for the API request
Returns:
dict: The updated Foundry agent details if the update was successful, otherwise None
"""
#
body = {
"agent_endpoint": {
"protocols": [
"responses",
"activity"
],
"authorization_schemes": [
{
"type": "Entra",
"isolation_key_source": {
"kind": "Entra"
}
},
{
"type": "BotServiceRbac"
}
]
}
}
response = requests.patch(
f"https://{account_name}.services.ai.azure.com/api/projects/{project_name}/agents/{agent_name}?api-version=v1",
headers={
"Content-Type": "application/merge-patch+json",
"Authorization": f"Bearer {token}",
"Foundry-Features": "AgentEndpoints=V1Preview"
},
json=body
)
if response.status_code == 200:
return response.json()
else:
logging.error(f"Failed to enable agent activity protocol: {response.status_code} - {response.text}")
return None
# Grab the principal_id of the Entra ID Agent Identity associated with the Foundry Agent
foundry_account_name = os.getenv("FOUNDRY_ACCOUNT_NAME")
project_name = os.getenv("FOUNDRY_PROJECT_NAME")
agent_name = os.getenv("FOUNDRY_AGENT_NAME")
enabled_agent = enable_agent_activity_protocol(foundry_account_name, project_name, agent_name, user_token.token)
enabled_agent_guid = enabled_agent.get('versions', {}).get("latest", {}).get("agent_guid", {})
print(f"Enabled Agent GUID: {enabled_agent_guid}")
updated_agent_endpoint = enabled_agent.get('agent_endpoint', {})
print(f"Updated Agent Endpoint: {json.dumps(updated_agent_endpoint, indent=2)}")

At this point, you have the Bot Service setup and you’ve activated the activity protocol for the agent so its now listening for requests at the messaging endpoint. The last step in the process is to use the publish operation and you will need the Foundry User role for this (as far as I can tell).

What exactly this does is still a bit of a black box for me, but it seems like it’s creating some type of API object to represent the agent in M365 Agent Registry (soon to be rebranded to Agent 365 I’m sure). Some of the APIs I need to poke around with require an Agents 365 license. Once I get that, I’ll update this section with more detail if I find exactly what it’s doing.

import os
import json
import requests
from dotenv import load_dotenv

# Load environmental variables
load_dotenv(override=True)

def publish_agent_teams(
    subscription_id: str,
    resource_group: str,
    account_name: str, 
    project_name: str, 
    location: str,
    agent_name: str, 
    agent_guid: str,
    bot_id: str,
    app_publish_scope: str,
    publish_as_digital_worker: bool,
    app_version: str,
    short_description: str,
    full_description: str,
    developer_name: str,
    developer_website_url: str,
    privacy_url: str,
    terms_of_use_url: str,
    token: str
    ):
    """This function uses the Foundry API to publish a Foundry agent to Microsoft Teams
    Args:
        subscription_id (str): The Azure subscription ID where the Foundry account is provisioned
        resource_group (str): The name of the resource group where the Foundry account is provisioned
        account_name (str): The name of the Foundry account
        project_name (str): The name of the Foundry project
        location (str): The Azure region where the Foundry account is provisioned
        agent_name (str): The name of the Foundry agent to publish
        agent_guid (str): The GUID of the Foundry agent to publish
        bot_id (str): The Microsoft App ID of the Bot registered in Entra ID for this agent
        app_publish_scope (str): The scope to publish the Teams app to, either "Individual" or "Tenant"
        publish_as_digital_worker (bool): Whether to publish the agent as a Digital Worker in Teams, which surfaces it in the Power Virtual Agents app in addition to allowing it to be installed as a standard Teams app
        app_version (str): The version of the Teams app to publish
        short_description (str): A short description of the agent to display in Teams
        full_description (str): A full description of the agent to display in Teams
        developer_name (str): The name of the developer or organization that created the agent, to display in Teams
        developer_website_url (str): The URL for the developer's website, to display in Teams
        privacy_url (str): The URL for the privacy policy for this agent, to display in Teams
        terms_of_use_url (str): The URL for the terms of use for this agent, to display in Teams
        token (str): The Entra ID access token with the scope of https://ai.azure.com/.default to authenticate the API request
    Returns:
        dict: The response from the Foundry API if the publish was successful, otherwise None
    """

    body = {
        "subscriptionId": subscription_id,
        "agentGuid": agent_guid,
        "agentName": agent_name,
        "appRegistrationId": appRegistrationId,
        "botId": bot_id,
        "appPublishScope": app_publish_scope,
        "publishAsDigitalWorker": publish_as_digital_worker,
        "appVersion": app_version,
        "shortDescription": short_description,
        "fullDescription": full_description,
        "developerName": developer_name,
        "developerWebsiteUrl": developer_website_url,
        "privacyUrl": privacy_url,
        "termsOfUseUrl": terms_of_use_url
    }

    response = requests.post(
        url = f"https://{location}.api.azureml.ms/agent-asset/v2.0/subscriptions/{subscription_id}/resourceGroups/{resource_group}/providers/Microsoft.MachineLearningServices/workspaces/{account_name}@{project_name}@AML/microsoft365/publish",
        headers={
            "Content-Type": "application/json", 
            "Accept": "application/json",
            "Authorization": f"Bearer {token}",
        },
        json=body
    ) 

    if response.status_code == 200:
        print("Agent published successfully! Status code: 200")
    else:
        logging.error(f"Failed to publish agent: {response.status_code} - {response.text}")
        return None

publish_response = publish_agent_teams(
    subscription_id = os.getenv("FOUNDRY_SUBSCRIPTION_ID"),
    resource_group = os.getenv("FOUNDRY_RESOURCE_GROUP"),
    account_name = os.getenv("FOUNDRY_ACCOUNT_NAME"),
    project_name = os.getenv("FOUNDRY_PROJECT_NAME"),
    location = os.getenv("FOUNDRY_LOCATION"),
    agent_name = os.getenv("FOUNDRY_AGENT_NAME"),
    agent_guid = enabled_agent_guid,
    bot_id = enabled_agent_guid,
    app_publish_scope = "Tenant",
    publish_as_digital_worker = False,
    app_version = "1.0.0",
    short_description = "This is a sample agent published from Foundry to Teams",
    full_description = "This agent was created in Foundry and published to Microsoft Teams using the Foundry API.",
    developer_name = "Carl Carlson",
    developer_website_url = "https://www.example.com",
    privacy_url = "https://www.example.com/privacy",
    terms_of_use_url = "https://www.example.com/terms",
    token = user_token.token
)

This step is effectively the last step in the Foundry Portal publishing experience. If you installed it for an individual it will be immediately available for that user. If you publish it to the Teams App Catalog (tenant option) it will be put in a pending state until approved via the M365 Admin Portal.

And like magic, you have a programmatic way to emulate the magical blue button in the Foundry portal. If you’re curious as to what that API call is going to an AML (Azure Machine Learning) endpoint, that is because (today at least) Foundry is built on top of AML.

Summing it up

What I’ve hoped you gathered from here is publishing an agent to Teams isn’t as simple as pushing a button. Requirements needs to be gathered, a design needs to be worked out, services chosen, service properties chosen for security and scale, services load tested, and security controls properly implemented and any risks accepted.

You have a ton of flexibility with this design and my take is there is no optimal design. The optimal design is the one that provides you with the user experience you require aligned with the risks your org is willing to accept. If you’re building an agent that is hitting some public data source, maybe you don’t care about any of this infrastructure. Either way, do not just hit the publish button, group up with your peers across security, networking, operations, collaboration, and AI engineering and put your heads together to come up with a design you’re all happy with.

With that, I’m out for Memorial Day weekend. See you next time!

Microsoft Foundry – Publishing Agents To Teams Deep Dive – Part 1

Microsoft Foundry – Publishing Agents To Teams Deep Dive – Part 1

This is part of my series on Microsoft Foundry:

  1. Microsoft Foundry’s Evolution
  2. Microsoft Foundry BYO AI Gateway (BYO Model) – Part 1
  3. Microsoft Foundry BYO AI Gateway (BYO Model) – Part 2
  4. Microsoft Foundry BYO AI Gateway (BYO Model) – Part 3
  5. Microsoft Foundry Publishing Foundry Agents to Microsoft Teams – Part 1
  6. Microsoft Foundry Publishing Foundry Agents to Microsoft Teams – Part 2

Welcome back folks! Today we’re gonna delve deep into the weeds to look at the current process for publishing Foundry agents to Microsoft Teams. I say current, because Microsoft Foundry and everything surrounding it lives in a dynamic world. Changes come fast and frequently. What I present today, may not be true in two weeks. I attempt to keep my posts up to date, but always remember to check the date of the post and review public documentation for the “official” word. With that disclaimer in play, let’s jump in.

The Background

Microsoft Foundry is a collection of a crapload of different services. I hesitate to call it a “product” because with how big the feature scope is it’s almost a platform rather than a product at this point. You have models-as-service, Foundry tools (FKA as AI Services FKA as Cognitive Services), Foundry Toolbox (LOVE this feature and will be writing something up about it soon), Content Understanding, Foundry IQ (not really Foundry IMO, more so AI Search but marketing loves the term Foundry), and Foundry Agent Service. I’m sure come Microsoft Build and Microsoft Ignite there will be even more in that umbrella. It’s an interesting journey on how this service came to be. You can take a read through my prior post which walks through the evolution of the service. For this post I’m going to focus specifically on Foundry Agents.

The Foundry Agent feature is probably one of the most dynamic features (or sub-service) of Microsoft Foundry because the technology area it supports changes daily which drives needs for the product to adapt and grow. Some of the benefits that pop into my mind of Foundry Agents vs running an agent on your own compute (in an on-premises Kubernetes cluster, in EKS, AKS, ACA, what have you) is:

  • Shift the management and scaling of the compute to Microsoft
  • Versioning out-of-the-box
  • Crank an agent out since 90% of the work is already done for you (Foundry Hosted Agents are another story)
  • Get access to all the Foundry Agent Tools out of the box (this benefit will likely be phased out with the introduction of Foundry Toolbox IMO)
  • Ability to directly publish the agent to Microsoft Teams without having to figure out that integration yourself

The final bullet point will be the focus of the rest of the post. Recently, I found time to play with that feature and decided to dive into it after a great blog post by my peer Graeme Foster. I highly recommend you take a read through Graeme’s post and treat his post as the “official” recommendation vs whatever I blather about here. The part that interested me about his write-up was the call out to Azure AI Bot Services. Bot Services is an Azure service I’ve touched a few times, but never really dove deeply into. Last year my buddy Mike Piskorski and I helped a customer onboard a Teams recording bot into a regulated organization’s network. We dove deeply into the networking side of things to get the traffic flowing, but never really dissected the inner workings of it (limited bandwidth, story of my life). Since exposing an agent direct to a user in Microsoft Teams so users can intact with it is a super common ask, and Foundry Agents provide for this out of the box, I thought it would be as good time as any to dig in.

The Portal Experience

The official documentation around the Portal process is documented here, but I wanted to dig into some the guts of it. Like Microsoft has done much of its existence, it likes to make things a “push of the blue button”. This integration is no different. After logging into the Foundry Portal and creating an agent I get a pretty Publish button in the top right hand corner as seen below.

I assume in my head, sweet, let’s do this! I hover over the button to click it, but oh no, the pop up below surfaces. For this button to be available to you, the user needs to have the Contributor or Owner at the resource group or subscription level. We’ll see why in a few minutes.

The pop up message tells me I can’t publish my Foundry resource if public network access is disabled (NOTE: this will be changing at some point). So what does this message mean? Without derailing this entire post with a deep dive into Foundry Agent networking, I’ll keep the explanation brief. For my Foundry deployment, I’ve chosen to block public inbound traffic to the Foundry resource and am forcing everything through a Private Endpoint. This control blocks whatever orchestration this button does today. One option is to enable public networking, push the button, and then disable public network access (not great). There is another programmatic option for customers that have public network access off and walk through in detail in my next post, but for now let’s enable public network access and step through the flow in the Portal.

Once public network access is re-enabled, I’m able to click the magical button. Pushing the button brings up the first window Publish to Teams and Microsoft 365. Here a few things happen. First, we’re given the option to customize what will ultimately be used to provide information about the agent to the Teams App Catalog or manifest file. I’m no Teams guy and won’t fake being one so my dumb guy explanation of the Teams App Catalog is its the central repository for Teams Apps (and agents) available for consumption by an organization’s Teams users. The manifest file is basically the same information put in json form that can be used to sideload the app (or agent in this use case) which is a way you can load the app into Teams for yourself and is typically used in testing scenarios before pushing up to the Teams App Catalog.

The other thing this step does is auto-provision an Bot Service resource in Azure. This is where the requirement for Contributor or Owner comes in (and is another reason why this GUI-drive process won’t work for most enterprises). It places it in the same resource group as the Foundry resource. This is probably not something you want happening automatically and this behavior is likely to change in the future allowing you to select a pre-created Bot Service resource. Think of the Bot Service resource as a metadata resource of sorts (again, my explanation and probably only 50% right, but I got a head nod on the explanation from my excellent and much smarter peer Shaun Callighan so there is that). It will help the Azure AI Bot Service facilitate the communication between Microsoft Teams and the Foundry Agent. I’ll dig into more details on later in this post.

I then hit the Next: Publish Options button and I’m faced with the Publish options window. Here I can choose to publish the agent to Teams just for my user or to publish it to the Teams App Catalog for all users (which will require a Teams administrator to approve). Optionally, I can download the Teams manifest file and further customize it (add a custom icon or something more fancy that is outside my limited Teams knowledge).

Publishing it to the Teams App Catalog will require an administrator to approve it in the Microsoft 365 Admin portal. The request will immediately appear in the Microsoft 365 Admin Portal in the request section as seen below. From there you’ll be given some options as to how you want to distribute it users across Microsoft Teams. After approval and installation, in my experience it can take a fair amount of time (6+ hours – 1 day) for the agent to be available to Teams users to use.

For the purposes of this walkthrough, I’m going to choose the Just you option and then I’ll hit the Publish button. Once complete, you’ll get a message indicating the publish was successful.

Bouncing over to the Microsoft Teams client, I see the new agent available to install.

Once it’s added and I send my first message to the agent, I’m prompted to sign in to Microsoft Foundry. This is triggered because the agent on the other side needs to know who I am in order to authorize me to access the agent.

If my user isn’t authorized (doesn’t hold the Foundry User (FKA as Azure AI User)) Azure RBAC role over the Foundry resource I’m denied and I can’t interact with the agent.

Understanding this user experience and RBAC requirement is important. While the Publish button can be used to push the agent as a Teams App to your users, the users themselves still need to hold the appropriate Azure RBAC role (Foundry User in this scenario or similar level permissions) to interact with them. While this is a tad annoying, it’s actually a nice belt and suspenders security control to ensure only trusted users get access to the agent.

Excellent, so we pushed a button and a lot of stuff happened. Well, what stuff happened? What if, like any normal enterprise, I don’t want to give my business unit contributor or owner at the Azure subscription level? What if, again like any normal enterprise, I have different groups in charge of Foundry, Azure general, and Teams? What if I want to do this programmatically? These were the questions on the top of my mind. So now let’s dive into the weeds and reverse engineer this process.

What the hell is happening when I push this button?

This is naturally what went through my head. Before I annoyed the awesome people within the Foundry product group (and yes, these are some of the nicest and smartest people at Microsoft I’ve dealt with in my years here) I wanted to see if I could figure it out myself.

My first step was to turn on debug mode in the browser and look at the network capture. My hope was that I’d see calls made to the Microsoft Graph API (for Teams stuff), the ARM API for Azure stuff, and Foundry data-plane API for data plane stuff. Instead of that, I saw calls made to what to the following endpoints:

  1. Press Publish to Teams and Microsoft 365 button
  2. Press Next: Publish Options
  3. Press Publish

What this told me was the Product Group has built their own orchestration layer on top of whatever is being done to the Microsoft Graph, ARM, and Foundry data-plane APIs. This didn’t get me any closer to figuring out what was going on. I had theories, but no way to validate them. At that point I went to the product group and one of those wonderful human beings began to peel away those layers of the onion by providing a programmatic way to run through process. I read through her code, converted it from PowerShell/Bicep into Python and Terraform and documented this high-level process. I’ll share and walk through all of this in my next post.

This is very high-level (we’ll look at the code-based implementation next post) but it’s the best I could piece together from the programmatic steps. It’s likely missing some steps because the one step I’m not super clear on is the step labeled Pending approval in M365 Admin Portal. The reason that piece is a bit unclear for me is two fold. One, even programmatically, this is done through a Foundry API hiding what’s done in other APIs from me. Two, I’m fairly certain it’s using new features the Microsoft Entra Agent Registry (now a part of Agent 365) and those APIs are largely locked behind Agent 365 licensing which I’m still waiting on approval for my tenant. My theory at this point is the Foundry API call is creating an agent instance within the Entra ID Agent Registry and/or something with CoPilot packages. I’ll add more detail to this if I get more insight into it once I get access to Agent 365.

Either way, once I had that high level workflow out of my brain and on digital paper, I was ready to take the product groups PowerShell / Bicep and rework it into a Jupyter Notebook which I’ll run through next post. Before I go there, I wanted to spend a bit of time on the Bot Service piece since that has always been a real mystery for me and many of my Azure peers.

What does the Bot Service do?

The Azure AI Bot Service (as it’s now called) has historically been used to build bots that can be exposed to Teams. I’m sure there are many other smarter uses, but that’s where I’ve seen it typically pop up in my time at Microsoft. As I mentioned earlier, my buddy Mike and I worked on helping a customer integrate a Teams Recording Bot that used the Bot Service. Today, the hot usage is exposing agents built within CoPilot Studio or Foundry to Teams as chat bots.

From an infra guy’s view, the Bot Service has always been this thing I knew existed, kinda understood how it worked from a network perspective and what it delivered form a value perspective, but really only focused on getting the traffic from Teams to the Bot Service into the application running the Bot Service Framework. Typically, this required exposing an application deployed to the customer’s private network to the Microsoft public backbone so it the Bot Service Connector (they relay piece of the service, my take) can hit the Bot app. This process would typically require placing the application behind a firewall with DNAT, behind an App Gateway (for l7 load balancing, WAF, and header checks) or some other layer 7 load balancer), behind FrontDoor in combination with PrivateLink, or behind something more complex such as a layer 7 load balancer in combination with an API Gateway (such as API Management) to do additional validation of the JWT as mentioned in Graeme’s blog I linked at the beginning of this post. Great, we got packets flowing, but much of the service was still a black box. I wanted to know more.

In my searching of the web, I came across an absolutely amazing blog post by Moim Hossain. Moim goes into an insane amount of detail as to how the Bot Service words under the hood. I’m not going to repeat everything he says, because I really anyone using Azure that will touch the Bot Service should read Moim’s rundown. It is THAT good.

Based on Moim’s blog (yeah I’m going to force you to read it if you want the details), I put together the high level flow of how I believe the Bot Service works. Likely missing pieces, but I feel like it’s more than what’s out there today.

After reading Moim’s blog and referencing the flow above we can see that the Bot Service is acting as a type of relay between the Microsoft Teams Service and the underling Bot Application. We can make a reasonable assumption (key word assumption) that the Foundry Agent integration is working somewhat similar, but with some differences given the additional RBAC check and nature of their push button integration.

If we crack open a Bot Service resource Foundry creates automatically, we can get some insights into how the Bot Service is being configured.

First we see that the messaging endpoint (the endpoint where the Bot Service Connector delivers messages it receives from Teams to) is the set in a format of https://FOUNDRY_ACCOUNT_NAME.services.ai.azure.com/api/projects/PROJECT_NAME/agents/AGENT_NAME/endpoint/protocols/activityprotocol?api-version=2025-11-15-preview. If we disable public network access and lock our Foundry resource behind a Private Endpoint, this URI would be unreachable by the Bot Service Connector. This creates the requirement I discussed earlier where we have to put some other infrastructure in front of those endpoint to make it available to the public IP world and mitigate the risks to do so (such as Graeme’s suggestions in his blog). Enabling Private Link for the Bot Service will not rid you of this requirement because that feature is centered around the use case of Direct Line which is more so used for custom built bots running in App Services (layman’s view) and only locks down the inbound access to the Bot Service. The integration with Teams requires the Bot Service stay public network access enabled, which means the traffic to the Bot App (agent) is going to come from the Microsoft public backbone driving this additional infrastructure requirement. This is where we’d modify the messaging endpoint to point to some other public IP-facing infra and route it to the Foundry Private Endpoint accordingly.

The next thing worth looking at is the Microsoft App ID. If we query this via the az cli with az ad sp show –id, we’ll see this maps to the Entra ID Agent Identity of the Foundry Agent. Anytime you create a Foundry Agent, an Entra ID Agent Identity Blueprint and Entra ID Agent Identity is provisioned inside of Entra ID. I plan on covering Entra ID Agent Identities in another post in the future, but for now think of them as a subclass of a service principal designed to cater to the security needs and ephemeral nature of agents. One of the best write-ups you can find online on this topic right now is from Christian Post. His series on the topic is worth a read.

By setting the Microsoft App ID to the agent’s Entra ID Agent Identity, we tie the bot service to the agent. We’ll see how this comes below.

Let’s take a look at a message coming from the Bot Service Connector into the agent. I captured this using an App Gateway + APIM pattern (similar to what is in Graeme’s post) and turned on request/response logging and captured all of the headers.

What we get is this:

[
{
"headers": {
"Authorization": {
"type": "Bearer",
"token": {
"header": {
"alg": "RS256",
"kid": "SERixAMWrs46-gqrTrtMrkfbnuE",
"x5t": "SERixAMWrs46-gqrTrtMrkfbnuE",
"typ": "JWT"
},
"payload": {
"serviceurl": "https://smba.trafficmanager.net/amer/6c80de31-d5e4-4029-93e4-5a2b3c0e1299/",
"nbf": 1778985043,
"exp": 1778988643,
"iss": "https://api.botframework.com",
"aud": "a6790ff6-8752-4654-8ad8-4129842d1042"
},
"signature": "hU7bOD-Awszt9zN07bwk7XtQ_E6hT1QGYgBQnDGxz75BF-QvO6gXBjCmo7FTjWizVXccen3mRi5xvSUIWO-vmrydJ9x5nSNaaVvIsHJm8T2agY3iOFDy_0Ii1t3uevJyiRqM_3T8Zi82T8P3umK6x3arkRbBzCWWQHJJs53pYm9m1lKyBax4jddjA3zBWltdcEtZixUEr9L73Qkoj4jU6d-QHyOxKAZnSJCaKzgAhtVOyQDMHU04PnPDNVKEQ5Efb2e5dx4Nqg2HoH1XQraa3zmE5_BGpIx1lIWPXA0oLaDLVnAhDEsS65H4mm48xCsR3l6VKgJc15pLPauTb5SoUw"
}
},
"Content-Length": "1098",
"Content-Type": "application/json; charset=utf-8",
"Host": "apim-example5ji.apim.XXXX.com",
"Max-Forwards": "10",
"User-Agent": "Microsoft-SkypeBotApi (Microsoft-BotFramework/3.0)",
"X-AppGW-Trace-Id": "8ca4348bdd71a1c004935143c7cf7cb0",
"X-ORIGINAL-HOST": "agent.XXXX.com",
"x-ms-conversation-id": "a:1Xl9msHS_A_eeI1hPHlVR_8OIrMzE90dFdnC8eYmn8UyRlMA4-VaE-Z5omzp-U8cu-PyufpeI08o9sxtVj2S_Wq_beuvR8VGThDKyePyyll8UqG3Wg7ZMmI5OBsVZMWY8",
"x-ms-tenant-id": "6c80de31-d5e4-4029-93e4-5a2b3c0e1299",
"MS-CV": "6KZTHFyq5rFY/64p/ywW+A.1.1.1.1.1011601833.1.1",
"X-FORWARDED-PROTO": "https",
"X-FORWARDED-PORT": "443",
"X-Forwarded-For": "52.112.116.120:15428;10.0.12.5",
"X-Original-URL": "/foundry/api/projects/sampleproject1/agents/published-agent-1/endpoint/protocols/activityprotocol?api-version=2025-11-15-preview",
"X-ARR-LOG-ID": "633dc336-3a11-4095-9540-d39a0cd99dc4",
"CLIENT-IP": "[fd40:5f98:1f:9145:6e4f:200:a00:c05]:48814",
"DISGUISED-HOST": "apim-example5ji.apim.XXXX.com",
"X-SITE-DEPLOYMENT-ID": "apimwebappF7goUDzbkpZ1MlRL4Klo4uAVLlNNaKbE2UiIMOxN__d61e",
"WAS-DEFAULT-HOSTNAME": "apimwebappf7goudzbkpz1mlrl4klo4uavllnnakbe2uiimoxn.azurewebsites.net",
"X-MS-PRIVATELINK-ID": "520132703",
"X-AppService-Proto": "https",
"X-ARR-SSL": "4096|256|CN=R12;O=Let's Encrypt;C=US|CN=apim-example5ji.apim.XXXX.com",
"X-Forwarded-TlsVersion": "1.3",
"X-WAWS-Unencoded-URL": "/foundry/api/projects/sampleproject1/agents/published-agent-1/endpoint/protocols/activityprotocol?api-version=2025-11-15-preview",
"X-Azure-JA4-Fingerprint": "t13d311100_e8f1e7e78f70_a11995863d32"
},
"severity": "Information",
"timestamp": "2026-05-17T02:30:43.8931561Z",
"source": "request-headers"
}
]

In the above I decoded the JWT included in the authorization header. In the JWT we can see that it’s been issued by https://api.botframework.com which jives to Moim’s blog in that the Bot Service has its own STS (security token service) which is used to generate access tokens to authenticate downstream to the Bot. The serviceUrl tells the agent where to send the response it generates for the user’s question. You’ll see that my tenant id is appended to this URL. The audience in this case maps to the published-agent-1’s Entra ID Agent Identity. As Graeme recommends, you can craft a simple policy in APIM to validate this information to assure the access token is coming from a trusted tenant and is intended for the agent its being sent to.

We also have a header called x-ms-tenant-id (thanks to my peer Shaun Callighan for pointing this out to me) which could also be checked at the App Gateway (or similar L7 gateway) to do some degree of validation. Not as good as a JWT validation Graeme suggested, but it’s something if a full fledged API Gateway is too much for you.

Summing it up for now

Okay, my brain is fried and I’m sure yours is too so I’m going to save walking through the programmatic process for tomorrow. For that post I’ll focus less on the whats and whys and more so on how the hows. At this point you should have a reasonable good understanding of what this button actually does and why this button will not be an option for most enterprises. The few callouts I’ll make:

  1. The publish button in the Foundry Portal (today) requires the user to have Contributor or Owner on the resource group or subscription because it automatically deploys a Bot Service resource to the resource group the Foundry resource is in. Likely a no go from the start for most enterprises.
  2. The publish button in Foundry Portal (today) requires the Foundry resource have public network access enabled. Likely a no go from the start for most enterprises.
  3. Once the agent is published to teams, the users interacting with it require the Foundry User role in order to interact with the agent. If they don’t have it, they’ll get an authorization failure when trying to chat with the bot.
  4. When public network access is disabled for the Foundry resource, you’ll need to find a way to make that endpoint accessible using a public IP. You have lots of patterns available to you here. At layer 4, you should be able to lock down inbound traffic to Teams traffic at 52.112.0.0/14 and 52.122.0.0/15. If you’re ingressing via a firewall, it’s a simple firewall rule. If you’re ingressing from an Application Gateway you can use a WAF rule. Wait for this to be officially documented in the Microsoft public documentation.

    In combination with the above, you should ideally go the route Graeme suggested and to incorporate some piece of infrastructure, such as APIM, that can do full validation of the JWT. Header validation of the x-ms-tenant-id is an option, but it’s not to the level of mitigation that full JWT validation is. Patterns for this include:
    • APIM v2 configured for public inbound and regional vnet integration
    • APIM classic configured for external mode
    • App Gateway with a public listener with APIM v2 VNet Injected or PE + regional vnet integration behind it (my preference)
    • App Gateway with a public listener with APIM classic VNet injection behind it (my preference)
    • Firewall DNAT + APIM v2 VNet Injected or PE + regional vnet integration behind it
    • Firewall DNAT + APIM classic VNet injection behind it
  5. There is an outbound flow from the Foundry Agent subnet (assuming you’re using Foundry Agents with VNet injection) that is sent to and endpoint at tenant.api.powerplatform.com (mine was il-6c80de31d5e4402993e45a2b3c0e12.99.tenant.api.powerplatform.com) where the 6c…….12.99 was my tenant id). I’m still trying to get clarity as to what this call is for. I’ll update this once I get it. I’d expect to see traffic to the endpoint in the serviceUrl but it looks like that traffic is flowing out the Microsoft side vs being tunneled into the customer virtual network even with Vnet injection (not uncommon for Foundry Agents w/ VNet injection).
  6. There is a programmatic way to do this without having to use the publish button which I’ll cover next post.

See you next post folks!