When you run an external identity system alongside Azure AD, sooner or later you need a bridge between the two. A common scenario is a partner portal or a B2B integration where users are managed in some external IAM product, but need to authenticate against Azure AD to reach your applications. Rather than migrating every external identity into Azure AD, you can build a small connector service that invites those users as Azure AD B2B guest users and keeps their group membership in sync. This article walks through building that connector using ASP.NET Core and Microsoft Graph.
Note before we start: the Graph SDK versions used in the original implementation this article is based on are older (Graph v4 style fluent API). If you are starting a new project today, use the current Microsoft Graph SDK v5, which has a different request builder syntax. The underlying Graph API calls and permission model described here are still accurate, only the client-side C# syntax has moved on.
Why a separate connector service
The design here deliberately keeps two Azure AD app registrations apart. One app registration belongs to the external identity system and only has permission to call your connector API. The second app registration belongs to the connector itself and is the only thing that talks to Microsoft Graph. This matters because Graph application permissions for managing users and groups are powerful. Directory.ReadWrite.All and User.ReadWrite.All on an application permission grant give that client the ability to touch every user and group in the tenant, not just guest accounts your connector created.
By putting the connector in the middle, you get one place where you enforce business rules such as only inviting guests, only assigning specific groups, and only working with users that came from the external system. The external caller never gets a Graph access token directly, it only gets an access token scoped to your connector API. If that external system is compromised, the blast radius is limited to whatever your connector API exposes, not the full Graph permission set.

Setting up the Microsoft Graph client
The connector authenticates to Graph using a confidential client with the client credentials flow. This is an application-only flow, there is no signed-in user involved anywhere in this exchange. The .default scope tells Azure AD to issue a token with whatever application permissions were granted (and admin-consented) on the app registration, rather than a specific delegated scope list.
public MsGraphService(IConfiguration configuration,
IOptions<GroupsConfiguration> groups,
ILogger<MsGraphService> logger)
{
_groups = groups.Value;
_logger = logger;
string[]? scopes = configuration.GetValue<string>
("AadGraph:Scopes")?.Split(' ');
var tenantId = configuration.GetValue<string>
("AadGraph:TenantId");
// Values from app registration
var clientId = configuration.GetValue<string>
("AadGraph:ClientId");
var clientSecret = configuration.GetValue<string>
("AadGraph:ClientSecret");
_federatedDomainDomain = configuration.GetValue<string>
("FederatedDomain");
var options = new TokenCredentialOptions
{
AuthorityHost = AzureAuthorityHosts.AzurePublicCloud
};
var clientSecretCredential = new ClientSecretCredential(
tenantId, clientId, clientSecret, options);
_graphServiceClient = new GraphServiceClient(
clientSecretCredential, scopes);
}
ClientSecretCredential from Azure.Identity handles token acquisition and caching internally, so you do not need to manage the token lifecycle yourself. In production, swap the client secret for a certificate credential where possible, since certificates are harder to leak through configuration files or environment dumps than a plain secret string.
The app registration used for Graph needs these application permissions granted with admin consent:
- Directory.Read.All
- Directory.ReadWrite.All
- Group.Read.All
- Group.ReadWrite.All
- RoleManagement.ReadWrite.Directory (only if you also assign directory roles, not just groups)
- User.Read.All
- User.ReadWrite.All
That is a broad set of permissions for a single application. Before granting it, check whether your tenant can scope Graph permissions further using Azure AD administrative units or resource-specific consent, especially if this connector will run in a shared tenant with other production workloads. Treat the client secret or certificate for this app registration as a high-value credential and rotate it on a schedule.
Securing the connector API itself
The external identity system does not call Graph directly. It calls your ASP.NET Core connector API, authenticating with its own client credentials against a second, separate app registration. This app registration only needs the .default scope for your own API’s app ID URI, no Graph permissions at all.
// 1. Client client credentials client
var app = ConfidentialClientApplicationBuilder
.Create(configuration["AzureAd:ClientId"])
.WithClientSecret(configuration["AzureAd:ClientSecret"])
.WithAuthority(configuration["AzureAd:Authority"])
.Build();
var scopes = new[] { configuration["AzureAd:Scope"] };
// 2. Get access token
var authResult = await app.AcquireTokenForClient(scopes)
.ExecuteAsync();
This is standard MSAL client credentials code. The important architectural point is not the code itself, it is that this token never has any Graph permission attached to it. Even if this token leaks, an attacker can only reach whatever endpoints your connector API exposes, and those endpoints are the ones you control and can lock down with request validation.
Inviting a guest user
Azure AD B2B invitations create a guest account and email the invited user a redemption link. The user authenticates with a one-time email code by default, there is no password to manage on your side. This works well for one-off external collaborators, but it is worth knowing upfront that Azure AD invitations only work against Azure AD tenants, not Azure AD B2C tenants, since B2C has an entirely different external identity model.
/// <summary>
/// Graph invitations only works for Azure AD, not Azure B2C
/// </summary>
public async Task<Invitation?> InviteUser(UserModel userModel, string redirectUrl)
{
var invitation = new Invitation
{
InvitedUserEmailAddress = userModel.Email,
SendInvitationMessage = true,
InviteRedirectUrl = redirectUrl,
InvitedUserType = "guest" // default is guest,member
};
var invite = await _graphServiceClient.Invitations
.Request()
.AddAsync(invitation);
return invite;
}
The call returns an Invitation object containing the InvitedUser reference, which is what you need for the next step, assigning groups to the newly created guest. One practical limitation to plan around: single sign-on is not available here unless every external user shares one domain and that domain supports SAML federation into Azure AD. Azure AD has no equivalent of “bring your own OpenID Connect provider” for arbitrary external domains, so if your external users span many different email domains, each one authenticates individually through the email code flow.
Assigning and removing group membership
Once a guest user exists in the tenant, group membership is how you drive authorization in your downstream applications. The pattern below compares the access roles coming from the external system against the groups the user currently belongs to, then adds or removes membership as needed.
public async Task AddRemoveGroupMembership(string userId,
List<string>? accessRolesPermissions, List<string>
currentGroupIds,
string groudId,
string groupType)
{
if (accessRolesPermissions != null &&
accessRolesPermissions.Any(g => g.Contains(groupType)))
{
await AddGroupMembership(userId, groudId, currentGroupIds);
}
else
{
await RemoveGroupMembership(userId, groudId, currentGroupIds);
}
}
private async Task AddGroupMembership(string userId, string groupId, List<string> currentGroupIds)
{
if (!currentGroupIds.Contains(groupId))
{
await AddUserToGroup(userId, groupId);
currentGroupIds.Add(groupId);
}
}
private async Task RemoveGroupMembership(string userId, string groupId,List<string> currentGroupIds)
{
if (currentGroupIds.Contains(groupId))
{
await RemoveUserFromGroup(userId, groupId);
currentGroupIds.Remove(groupId);
}
}
public async Task<User?> UserExistsAsync(string email)
{
var users = await _graphServiceClient.Users
.Request()
.Filter($"mail eq '{email}'")
.GetAsync();
if (users.CurrentPage.Count == 0)
return null;
return users.CurrentPage[0];
}
public async Task DeleteUserAsync(string userId)
{
await _graphServiceClient.Users[userId]
.Request()
.DeleteAsync();
}
private async Task RemoveUserFromGroup(string userId, string groupId)
{
try
{
await _graphServiceClient.Groups[groupId]
.Members[userId]
.Reference
.Request()
.DeleteAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "{Error} RemoveUserFromGroup", ex.Message);
}
}
private async Task AddUserToGroup(string userId, string groupId)
{
try
{
var directoryObject = new DirectoryObject
{
Id = userId
};
await _graphServiceClient.Groups[groupId]
.Members
.References
.Request()
.AddAsync(directoryObject);
}
catch (Exception ex)
{
_logger.LogError(ex, "{Error} AddUserToGroup", ex.Message);
}
}
A few things worth calling out about this code. First, there is no bulk membership update endpoint being used here, each add or remove is a separate Graph call, so if a user’s role changes and three groups need updating, that is three round trips to Graph. For high-volume scenarios you would want to batch these calls or move to Graph’s $batch endpoint. Second, the failures inside AddUserToGroup and RemoveUserFromGroup are caught and logged but swallowed rather than surfaced to the caller, which means the API response might report success even though a group assignment silently failed. If group membership is security-critical in your setup, propagate that error instead of only logging it. Third, deleting an Azure AD user with an application permission is a hard delete path, since guest accounts created this way cannot be disabled through the application permission set the way member accounts can with directory role assignments. If the external IAM marks a user inactive, this connector removes the Azure AD guest account entirely rather than soft-disabling it.
Creating a guest user with group assignment in one flow
The service layer combines invitation and group assignment into a single operation, which is what the external system actually calls.
public async Task<(UserModel? UserModel, string Error)>
CreateUserAsync(UserModel userModel)
{
var emailValid = _msGraphService.IsEmailValid(userModel.Email);
if (!emailValid)
{
return (null, "Email is not valid");
}
var user = await _msGraphService.UserExistsAsync(userModel.Email);
if (user != null)
{
return (null, "User with this email already exists in AAD tenant");
}
var result = await _msGraphService.InviteUser(userModel,
_configuration["InviteUserRedirctUrl"]);
if (result != null)
{
await AssignmentGroupsAsync(
result.InvitedUser.Id,
userModel.AccessRolesPermissions,
new List<string>());
}
return (userModel, string.Empty);
}
Notice the comment in the original implementation about a timing constraint: you cannot reliably read back a just-created user or group from Graph for a short window after creation, since Graph replicates data across the directory asynchronously. That is exactly why this method uses the InvitedUser.Id returned directly from the invitation response instead of doing a follow-up lookup. If you ever need to look up a resource immediately after creating it and a null or 404 comes back unexpectedly, this replication delay is usually the cause, not a bug in your code. Retrying with backoff or queuing the follow-up work is the usual workaround.
private async Task UpdateAssignmentGroupsAsync(string userId, List<string>? accessRolesPermissions)
{
var currentGroupIds = await _msGraphService.GetGraphUserMemberGroups(userId);
var currentGroupIdsList = currentGroupIds.ToList();
await AssignmentGroupsAsync(userId, accessRolesPermissions, currentGroupIdsList);
}
private async Task AssignmentGroupsAsync(string userId,
List<string>? accessRolesPermissions, List<string> currentGroupIds)
{
await _msGraphService.AddRemoveGroupMembership(userId,
accessRolesPermissions, currentGroupIds, _groups.UserWorkspace, Consts.USER_WORKSPACE);
await _msGraphService.AddRemoveGroupMembership(userId,
accessRolesPermissions, currentGroupIds, _groups.AdminWorkshop, Consts.ADMIN_WORKSPACE);
}
AssignmentGroupsAsync maps your application’s own permission model (things like “UserWorkspace” or “AdminWorkshop”) onto specific Azure AD group object IDs. Keeping this mapping in code rather than hardcoding group object IDs throughout the codebase makes it much easier to change group assignments later without touching business logic elsewhere.
[HttpPost("Create")]
[ProducesResponseType(StatusCodes.Status201Created,
Type = typeof(UserModel))]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[SwaggerOperation(OperationId = "Create-AAD-guest-Post",
Summary = "Creates an Azure AD guest user with assigned groups")]
public async Task<ActionResult<UserModel>> CreateUserAsync(
[FromBody] UserModel userModel)
{
var result = await _userGroupManagememtService
.CreateUserAsync(userModel);
if (result.UserModel == null)
return BadRequest(result.Error);
return Created(nameof(UserModel), result.UserModel);
}
This controller action is the only entry point the external system calls, and it is protected by the connector app registration’s access token, not by the Graph token. That separation is the whole point of this design, the caller never gets anywhere near raw Graph permissions.
Updating or removing a guest user
The update path handles two cases in one method: an active user gets their profile and group assignments refreshed, an inactive user gets deleted entirely.
public async Task<(CreateUpdateResult? Result, string Error)>
UpdateDeleteUserAsync(UserUpdateModel userModel)
{
var emailValid = _msGraphService.IsEmailValid(userModel.Email);
if (!emailValid)
{
return (null, "Email is not valid");
}
var user = await _msGraphService.UserExistsAsync(userModel.Email);
if (user == null)
{
return (null, "User with this email does not exist");
}
if (userModel.IsActive)
{
user.GivenName = userModel.FirstName;
user.Surname = userModel.LastName;
user.DisplayName = $"{userModel.FirstName} {userModel.LastName}";
await _msGraphService.UpdateUserAsync(user);
await UpdateAssignmentGroupsAsync(user.Id,
userModel.AccessRolesPermissions);
return (new CreateUpdateResult
{
Succeeded = true,
Reason = $"{userModel.Email} {userModel.Username} updated"
}, string.Empty);
}
else // not active, remove
{
await UpdateAssignmentGroupsAsync(user.Id, null);
await _msGraphService.DeleteUserAsync(user.Id);
return (new CreateUpdateResult
{
Succeeded = true,
Reason = $"{userModel.Email} {userModel.Username} removed"
}, string.Empty);
}
}
Two production considerations here. Profile changes made through Graph do not take effect in the user’s current session, they apply on the next authentication. If your application needs changes to be visible immediately, such as a permission downgrade for security reasons, you need a separate mechanism to force re-authentication, for example revoking refresh tokens with the Graph revokeSignInSessions action. Second, calling UpdateAssignmentGroupsAsync with null access roles before deleting the user strips all group memberships first. That is a reasonable defensive step, since it avoids leaving orphaned group memberships behind even if the delete call itself fails partway through.
[HttpPost("UpdateUser")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(CreateUpdateResult))]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[SwaggerOperation(OperationId = "Update-AAD-guest-Post", Summary = "Updates or deletes an Azure AD guest user and assigned groups")]
public async Task<ActionResult<CreateUpdateResult>> UpdateUserAsync([FromBody] UserUpdateModel userModel)
{
var update = await _userGroupManagememtService
.UpdateUserAsync(userModel);
if (update.Result == null)
return BadRequest(update.Error);
return Ok(update.Result);
}
Notice this single endpoint handles both update and delete depending on the IsActive flag in the request body, rather than exposing separate update and delete routes. That is a reasonable simplification for an internal sync connector where the external system always sends the current state of a user, but it does mean a caller cannot distinguish a delete request from an update request just by looking at the HTTP verb, which is worth documenting clearly for whoever consumes this API.
Testing with a console client
Because the connector API requires a client credentials token, testing it means acquiring a token the same way the real external system would, then calling the API with a bearer token attached.
static async Task<HttpResponseMessage> CreateUser(IConfigurationRoot configuration,
AuthenticationResult authResult)
{
var client = new HttpClient
{
BaseAddress = new Uri(configuration["AzureAd:ApiBaseAddress"])
};
client.DefaultRequestHeaders.Authorization
= new AuthenticationHeaderValue("Bearer", authResult.AccessToken);
client.DefaultRequestHeaders.Accept
.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var response = await client.PostAsJsonAsync("AadUsers/Create",
new UserModel
{
Username = "paddy@test.com",
Email = "paddy@test.com",
FirstName = "Paddy",
LastName = "Murphy",
AccessRolesPermissions = new List<string> { "UserWorkspace" }
});
return response;
}
A simple .NET console app like this is enough to exercise the whole flow end to end during development, without needing the real external IAM system wired up yet. Keep this kind of throwaway client around in your test project, it saves time whenever you need to reproduce a bug against a specific tenant.
The single sign-on limitation
It is worth restating the biggest limitation of this whole pattern clearly, since it affects whether this approach fits your requirements at all. Azure AD guest accounts created through invitation authenticate with an email one-time code by default. There is no built-in way to register an arbitrary external OpenID Connect provider so that Azure AD’s own sign-in page federates out to it per domain. Google, Facebook, and SAML-based domain federation are supported, but a generic OIDC external identity provider per domain is not.
If every external user shares one email domain, SAML federation into that domain can give you real single sign-on. If your external users come from many different domains, as is common in a multi-tenant B2B scenario, they will each go through the individual email code flow rather than a unified login experience. That is a real UX cost worth flagging to stakeholders before committing to this architecture, and it is often the deciding factor between building a connector like this versus adopting a dedicated external identity platform such as Azure AD B2C or a third-party CIAM product.
Leave a Reply