When you automate user offboarding in Azure AD, you usually do not have a signed-in admin sitting around to click buttons. A background job, a scheduled function, or an HR system integration needs to disable or remove an account on its own. Microsoft Graph supports this through an application client using OAuth client credentials, and this post walks through three practical ways to handle account offboarding: disabling the account, deleting it outright, or pulling the user out of the security groups that grant access.
This is a straightforward but genuinely useful reference if you are building any kind of automated identity lifecycle process, and it is worth understanding the trade-offs of each approach before you pick one for production.
Application permissions versus delegated permissions
Microsoft Graph supports two permission models. Delegated permissions require a signed-in user and an interactive consent flow, which does not work for a background service. Application permissions, on the other hand, let a client authenticate as itself using a client secret or certificate, with no user involved at all. For an offboarding pipeline triggered by an HR event or a scheduled job, application permissions are the only sensible choice, since there is nobody around to grant consent at runtime.
The catch with application permissions is that they tend to be broader than delegated ones. A delegated scope is naturally bounded by what the signed-in user is allowed to do, but an application permission like User.ReadWrite.All grants the same access regardless of who or what is calling it. You need to be deliberate about where you store the client secret or certificate, and which app registration gets this permission, because a compromised credential here has real blast radius across your tenant.
Setting up the Graph client
The client credentials flow needs a tenant ID, a client ID, and either a client secret or a certificate from the app registration. The example below wires up a GraphServiceClient using ClientSecretCredential from the Azure Identity library.
public MsGraphService(IConfiguration configuration,
ILogger<MsGraphService> logger)
{
_groups = configuration.GetSection("Groups").Get<List<GroupsConfiguration>>();
_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
};
// https://docs.microsoft.com/dotnet/api/azure.identity.clientsecretcredential
var clientSecretCredential = new ClientSecretCredential(
tenantId, clientId, clientSecret, options);
_graphServiceClient = new GraphServiceClient(clientSecretCredential, scopes);
}
Nothing unusual here for anyone who has wired up client credentials before. The client secret is read from configuration, which in a real deployment should come from a key vault rather than appsettings.json. For anything customer facing, swap the secret for a certificate credential instead, since certificates are harder to leak accidentally and easier to rotate cleanly. This service gets reused across all three offboarding approaches below, so the setup cost is paid only once.
Option 1: Toggle the AccountEnabled property
The most direct way to offboard a user is to flip the AccountEnabled flag to false. The account and all its data stay intact, but the user can no longer sign in anywhere in the tenant. This requires the User.ReadWrite.All application permission, and it is worth calling out explicitly because it is easy to assume a narrower permission would cover it and then spend time debugging a 403 that turns out to be a permissions problem, not a code problem.
user.GivenName = userModel.FirstName;
user.Surname = userModel.LastName;
user.DisplayName = $"{userModel.FirstName} {userModel.LastName}";
user.AccountEnabled = userModel.IsActive;
await _msGraphService.UpdateUserAsync(user);
This snippet builds a User object from your own domain model and pushes it through the update call below. The actual Graph call is a thin wrapper.
public async Task<User> UpdateUserAsync(User user)
{
return await _graphServiceClient.Users[user.Id]
.Request()
.UpdateAsync(user);
}
This is a PATCH under the hood, so only the properties you set on the User object get sent to Graph. Disabling the account this way is reversible: flip AccountEnabled back to true later and the user is fully restored, group memberships and all. That reversibility is exactly why this is usually the right default for offboarding, over deleting the account outright.
Option 2: Delete the user
Sometimes disabling is not enough and the account genuinely needs to go, for example when a contractor’s engagement ends and there is no chance of a rehire. Deleting a user is a single call.
public async Task DeleteUserAsync(string userId)
{
await _graphServiceClient.Users[userId]
.Request()
.DeleteAsync();
}
Azure AD soft-deletes users for about 30 days before a permanent purge, so there is a short recovery window through the Graph deleted items API if this turns out to be a mistake. Even so, treat deletion as a one-way door in your process design. If the same person could plausibly return, for example a seasonal employee, deletion forces you to recreate the account and group memberships from scratch, which is more work than most teams expect when it actually happens.
Option 3: Remove the user from security groups
Deleting or disabling the whole account is often heavier than what the situation calls for. A user frequently needs access removed from one application or service, not from the entire directory, because they still use other systems tied to the same Azure AD tenant. If your applications gate access through security group membership rather than checking the user object directly, you can revoke access to a specific service by removing the person from that service’s groups and leave the rest of the account untouched.
public async Task RemoveUserFromAllGroupMemberships(string userId)
{
var currentGroupIds = await GetGraphUserMemberGroups(userId);
var currentGroupIdsList = currentGroupIds.ToList();
// Only delete specific groups we defined in this app.
foreach (var group in _groups)
{
if(currentGroupIdsList.Contains(group.GroupId))
// remove group
await RemoveUserFromGroup(userId, group.GroupId);
currentGroupIds.Remove(group.GroupId);
}
}
This method fetches the groups the user currently belongs to, then loops through only the groups your application actually cares about, defined in the _groups configuration. This scoping matters: you do not want an offboarding routine for one application accidentally stripping group memberships that belong to a completely different system. Each removal goes through the following call.
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);
}
}
Note the try-catch here. Removing a member reference can fail if the user was already removed from the group by something else, and swallowing that specific failure with a log entry is reasonable rather than letting the whole offboarding job crash over a group membership that is already gone. The obvious limitation of this approach is that it only works if group membership is genuinely the access control mechanism for every downstream service. If even one application checks something else, like a direct role assignment or a claim baked into the user object, that access will not be touched by this code at all.
Choosing the right approach
In practice these three options are not mutually exclusive. A common pattern is to remove group memberships immediately when access needs to be cut for a specific reason, disable the account fully once HR confirms the employee has left, and only delete the account after a retention period has passed with no need to reinstate it. Building this as a single automated pipeline, triggered by an HR system webhook and backed by Azure Functions or Logic Apps, turns a manual IT ticket into something that happens consistently and immediately instead of days later when someone remembers to do it.
Whichever option you build first, test what happens to existing sessions and tokens after you make the change. Disabling or deleting a user does not necessarily invalidate an access token that was already issued and has not expired yet, so pair this with short token lifetimes or Continuous Access Evaluation if immediate cutoff matters for your scenario.
Leave a Reply