Every enterprise IT team ends up building the same internal tool eventually: a way for an admin or a support engineer to reset a user’s password without going into the Azure portal every single time. This article walks through building that tool properly, using a Microsoft Graph application client in ASP.NET Core, backed by an Azure App registration that holds the User Administrator role. This is an app-only flow, meaning there is no signed-in user driving the reset; the application itself acts on behalf of the organization.
This pattern is common in internal admin portals, IT helpdesk tools and automated onboarding pipelines. It is worth understanding both how it works and where it can go wrong, because a password reset endpoint is exactly the kind of feature attackers look for first.
Setting up the Azure App registration
The first step happens entirely in the Azure portal. Create an Azure App registration and give it a secret or, better, a certificate. This registration needs the application permission User.ReadWrite.All on Microsoft Graph, granted with admin consent. Note that this is an application permission, not a delegated one, so it only works for daemon-style clients where there is no interactive user context.
It is worth being precise about scope here. User.ReadWrite.All is a broad permission; it lets the client update any user in the tenant, not just reset passwords. If your Graph SDK or tenant policy supports narrower alternatives for your scenario, prefer them. When that is not possible, at minimum restrict the calling code path tightly, because the App registration credential is a high value target once this is deployed.

Assigning the User Administrator role
Holding a Graph permission alone is not enough to reset passwords for tenant members. The Azure App registration’s corresponding Enterprise application also needs the User Administrator directory role. This is assigned from the Azure AD roles and administrators blade, where you search for User Administrator and add a new role assignment.

When adding the assignment, pick the Enterprise application that corresponds to your App registration, not a random service principal with a similar name; this is a common mistake when the app was registered under a slightly different display name. Set the assignment type to Active rather than Eligible if you are not layering PIM on top of this, since an application client cannot activate a PIM-eligible role on its own the way an interactive user can.

Building the Microsoft Graph application client
With the Azure side configured, the ASP.NET Core application needs a Graph client that authenticates as the application, not as a user. The cleanest way to do this is with Azure.Identity’s ChainedTokenCredential, which lets you use a managed identity in production and a client secret locally during development, without branching your business logic.
using Azure.Identity;
using Microsoft.Graph;
namespace SelfServiceAzureAdPasswordReset;
public class GraphApplicationClientService
{
private readonly IConfiguration _configuration;
private readonly IHostEnvironment _environment;
private GraphServiceClient? _graphServiceClient;
public GraphApplicationClientService(IConfiguration configuration, IHostEnvironment environment)
{
_configuration = configuration;
_environment = environment;
}
/// <summary>
/// gets a singleton instance of the GraphServiceClient
/// </summary>
public GraphServiceClient GetGraphClientWithManagedIdentityOrDevClient()
{
if (_graphServiceClient != null)
return _graphServiceClient;
string[] scopes = new[] { "https://graph.microsoft.com/.default" };
var chainedTokenCredential = GetChainedTokenCredentials();
_graphServiceClient = new GraphServiceClient(chainedTokenCredential, scopes);
return _graphServiceClient;
}
private ChainedTokenCredential GetChainedTokenCredentials()
{
if (!_environment.IsDevelopment())
{
// You could also use a certificate here
return new ChainedTokenCredential(new ManagedIdentityCredential());
}
else // dev env
{
var tenantId = _configuration["AzureAdGraph:TenantId"];
var clientId = _configuration.GetValue<string>("AzureAdGraph:ClientId");
var clientSecret = _configuration.GetValue<string>("AzureAdGraph:ClientSecret");
var options = new TokenCredentialOptions
{
AuthorityHost = AzureAuthorityHosts.AzurePublicCloud
};
var devClientSecretCredential = new ClientSecretCredential(
tenantId, clientId, clientSecret, options);
var chainedTokenCredential = new ChainedTokenCredential(devClientSecretCredential);
return chainedTokenCredential;
}
}
}
The scope requested here is always https://graph.microsoft.com/.default, which tells Azure AD to issue a token containing whatever application permissions were granted on the App registration, in this case User.ReadWrite.All. The GraphServiceClient instance is cached as a singleton field so it is not recreated on every call, which matters because token acquisition and client construction are not free. In production the ManagedIdentityCredential removes the need to store any secret at all, provided the App Service or container has a managed identity assigned and that identity holds the same Graph permission and role.
Resetting the password with Microsoft Graph SDK 4
Graph SDK 4 and SDK 5 have noticeably different APIs, and at the time of writing a lot of production code still runs on SDK 4 because migration guidance for this specific scenario is thin. The SDK 4 version below looks up the user by filtering on userPrincipalName, then issues an update with a new PasswordProfile.
using Microsoft.Graph;
using System.Security.Cryptography;
namespace SelfServiceAzureAdPasswordReset;
public class UserResetPasswordApplicationGraphSDK4
{
private readonly GraphApplicationClientService _graphApplicationClientService;
public UserResetPasswordApplicationGraphSDK4(GraphApplicationClientService graphApplicationClientService)
{
_graphApplicationClientService = graphApplicationClientService;
}
private async Task<string> GetUserIdAsync(string email)
{
var filter = $"startswith(userPrincipalName,'{email}')";
var graphServiceClient = _graphApplicationClientService
.GetGraphClientWithManagedIdentityOrDevClient();
var users = await graphServiceClient.Users
.Request()
.Filter(filter)
.GetAsync();
return users.CurrentPage[0].Id;
}
public async Task<string?> ResetPassword(string email)
{
var graphServiceClient = _graphApplicationClientService
.GetGraphClientWithManagedIdentityOrDevClient();
var userId = await GetUserIdAsync(email);
if (userId == null)
{
throw new ArgumentNullException(nameof(email));
}
var password = GetRandomString();
await graphServiceClient.Users[userId].Request()
.UpdateAsync(new User
{
PasswordProfile = new PasswordProfile
{
Password = password,
ForceChangePasswordNextSignIn = true
}
});
return password;
}
private static string GetRandomString()
{
var random = $"{GenerateRandom()}{GenerateRandom()}{GenerateRandom()}{GenerateRandom()}-AC";
return random;
}
private static int GenerateRandom()
{
return RandomNumberGenerator.GetInt32(100000000, int.MaxValue);
}
}
A few things are worth flagging here. GetUserIdAsync uses startswith on userPrincipalName, which is a reasonable lookup but assumes uniqueness; in a tenant with guest accounts or similarly named UPNs this filter can match more than one record, and the code blindly takes CurrentPage[0]. In a real deployment, check the result count and fail loudly rather than silently resetting the wrong account. The generated password uses RandomNumberGenerator, which is the correct cryptographically secure choice over Random, and ForceChangePasswordNextSignIn = true is what makes this safe to hand over as a temporary password rather than a permanent one.
Resetting the password with Microsoft Graph SDK 5
SDK 5 reworks the query syntax around a fluent requestConfiguration delegate and adds the ConsistencyLevel eventual header requirement for advanced queries like Search and Count. The logic is functionally the same as SDK 4: find the user, then patch the PasswordProfile.
using Microsoft.Graph;
using Microsoft.Graph.Models;
using System.Security.Cryptography;
namespace SelfServiceAzureAdPasswordReset;
public class UserResetPasswordApplicationGraphSDK5
{
private readonly GraphApplicationClientService _graphApplicationClientService;
public UserResetPasswordApplicationGraphSDK5(GraphApplicationClientService graphApplicationClientService)
{
_graphApplicationClientService = graphApplicationClientService;
}
private async Task<string?> GetUserIdAsync(string email)
{
var filter = $"startswith(userPrincipalName,'{email}')";
var graphServiceClient = _graphApplicationClientService
.GetGraphClientWithManagedIdentityOrDevClient();
var result = await graphServiceClient.Users.GetAsync((requestConfiguration) =>
{
requestConfiguration.QueryParameters.Top = 10;
if (!string.IsNullOrEmpty(email))
{
requestConfiguration.QueryParameters.Search = $"\"userPrincipalName:{email}\"";
}
requestConfiguration.QueryParameters.Orderby = new string[] { "displayName" };
requestConfiguration.QueryParameters.Count = true;
requestConfiguration.QueryParameters.Select = new string[] { "id", "displayName", "userPrincipalName", "userType" };
requestConfiguration.QueryParameters.Filter = "userType eq 'Member'"; // onPremisesSyncEnabled eq false
requestConfiguration.Headers.Add("ConsistencyLevel", "eventual");
});
return result!.Value!.FirstOrDefault()!.Id;
}
public async Task<string?> ResetPassword(string email)
{
var graphServiceClient = _graphApplicationClientService
.GetGraphClientWithManagedIdentityOrDevClient();
var userId = await GetUserIdAsync(email);
if (userId == null)
{
throw new ArgumentNullException(nameof(email));
}
var password = GetRandomString();
await graphServiceClient.Users[userId].PatchAsync(
new User
{
PasswordProfile = new PasswordProfile
{
Password = password,
ForceChangePasswordNextSignIn = true
}
});
return password;
}
private static string GetRandomString()
{
var random = $"{GenerateRandom()}{GenerateRandom()}{GenerateRandom()}{GenerateRandom()}-AC";
return random;
}
private static int GenerateRandom()
{
return RandomNumberGenerator.GetInt32(100000000, int.MaxValue);
}
}
The SDK 5 version filters on userType eq ‘Member’ as well, which is a good habit to copy even if you are still on SDK 4. Without it, an application permission client can accidentally reset a guest account’s password, which is rarely what an internal helpdesk tool is meant to do. The Filter and Search combination on the Users endpoint also needs the ConsistencyLevel eventual header; leaving it out is one of the most common runtime errors people hit when they first move advanced queries from SDK 4 to SDK 5, since Graph returns a 400 without always making the missing header obvious in the error text.
Wiring the reset service into a Razor Page
Once the reset service exists, exposing it from a Razor Page is straightforward dependency injection. The interesting part is not the code below, it is everything you must add around it before this is safe to expose.
private readonly UserResetPasswordApplicationGraphSDK5
_userResetPasswordApp;
[BindProperty]
public string Upn { get; set; } = string.Empty;
[BindProperty]
public string? Password { get; set; } = string.Empty;
public IndexModel(UserResetPasswordApplicationGraphSDK5
userResetPasswordApplicationGraphSDK4)
{
_userResetPasswordApp =
userResetPasswordApplicationGraphSDK4;
}
public void OnGet(){}
public async Task<IActionResult> OnPostAsync()
{
if (!ModelState.IsValid)
{
return Page();
}
Password = await _userResetPasswordApp
.ResetPassword(Upn);
return Page();
}
This page takes a UPN from the request and hands back a generated password, and that is precisely why it cannot be left open on a public endpoint as-is. At minimum this page needs authentication so only known IT staff or a verified requester can reach it, rate limiting or a delay after each submission to blunt scripted abuse, and bot protection such as a CAPTCHA if it is ever exposed outside a corporate network. Anyone who can call OnPostAsync freely can attempt to reset arbitrary users’ passwords, so treat this endpoint with the same scrutiny as a login form, not as an internal utility nobody will look at twice.

After a reset, the affected user can verify and manage the new password at https://mysignins.microsoft.com/security-info, which is also where they would register MFA methods if they have not already done so.
When to use this, and when not to
This application permission approach fits scenarios with no interactive user driving the action, such as an automated onboarding pipeline or a batch job that provisions accounts and sets initial passwords. If a human administrator or the end user themselves is initiating the reset through a sign-in session, a delegated permission model is the better fit, since it ties the action to that person’s own identity and audit trail rather than to a shared application credential.
Break glass and other privileged accounts deserve extra care regardless of which model you pick. Ideally those accounts already use FIDO2 or another passwordless method with no password fallback, so this reset flow simply should not apply to them. If your implementation can technically reset a Global Administrator’s password through this same code path, that is a gap worth closing before this goes anywhere near production, for example by excluding privileged roles from the lookup query entirely.
One more practical note: this article does not cover audit logging, but any admin-assisted password reset tool absolutely needs one. Log who triggered the reset, for which account, and when, ideally by subscribing to Graph change notifications or writing to your own audit store, so a compromised or misused reset tool leaves a trail.
Leave a Reply