Onboarding users in ASP.NET Core using Microsoft Entra ID Temporary Access Pass and Microsoft Graph

Onboarding a new employee into Microsoft Entra ID sounds simple until you actually build the flow. You need to create the account, hand over credentials securely, and ideally push the person straight into passwordless authentication instead of one more password nobody will remember. This article walks through an ASP.NET Core sample that automates this using Microsoft Graph and a Temporary Access Pass, along with the fallback paths for users who cannot go passwordless yet.

The scenario is not one size fits all. A typical tenant has employees who can register a FIDO2 key on day one, contractors who still need a password because their device does not support passwordless, and external guests who are not full members of the tenant at all. The sample in this article handles all three cases.

Why one onboarding flow is not enough

In a real tenant you usually end up supporting several onboarding paths, depending on the type of user and device involved.

  • Member user flow with TAP and FIDO2 authentication
  • Member user flow with password using email and password
  • Member user flow with password setup and phone authentication
  • Guest user flow with federated login
  • Guest user flow with a Microsoft account
  • Guest user flow with an email code

FIDO2 should be the default for anyone with an office account. If a full FIDO2 rollout is not possible yet, enforce it at least for IT administrators, since their accounts carry the highest risk if compromised. Pair this with Conditional Access step up policies so privileged actions always demand a phishing resistant credential, not just the first sign in of the day.

Users without a computer, think shop floor or warehouse staff, typically end up on email code or SMS authentication instead. This is weak from a security standpoint, so treat these identities as low trust and avoid exposing sensitive data to sessions authenticated this way.

Setting up the application and Graph permissions

The ASP.NET Core app is server rendered, not a single page app, and it uses Microsoft.Identity.Web along with Microsoft.Identity.Web.MicrosoftGraphBeta to talk to Entra ID. Server side rendering matters here because the app holds an application level Graph token capable of creating and modifying accounts across the whole tenant, not something you want sitting in a browser accessible SPA.

The Graph calls run under application permissions rather than delegated ones, since there is no signed in admin driving the request at onboarding time. Three permissions cover the entire workflow.

  • User.EnableDisableAccount.All
  • User.ReadWrite.All
  • UserAuthenticationMethod.ReadWrite.All

These are broad, admin consent only permissions, and that is worth pausing on. An app registration holding User.ReadWrite.All and UserAuthenticationMethod.ReadWrite.All at the application level can create, modify, or effectively take over any account in the tenant, including administrator accounts.

Isolate this registration from other app registrations and restrict who can add secrets to it. A managed identity is a better fit than a client secret when the app runs inside Azure, since it removes a credential that could otherwise leak. Delegated permissions tied to a signed in HR admin would give a tighter blast radius for a production rollout, and the original author flags this as a planned next step.

Onboarding members with TAP and passwordless

Creating a passwordless member account takes two Graph calls that cannot run as a single atomic operation. First the user gets created, then a Temporary Access Pass gets attached to it. Entra ID needs some unpredictable propagation time between the two calls, so the code wraps the TAP request in a retry policy built with Polly instead of assuming it succeeds on the first attempt.

private async Task CreateMember(UserModel userData)
{
    var createdUser = await _meIdGraphSdkManagedIdentityAppClient
                    .CreateGraphMemberUserAsync(userData);
 
    if (createdUser!.Id != null)
    {
        if (userData.UsePasswordless)
        {
            var maxRetryAttempts = 20;
            var pauseBetweenFailures = TimeSpan.FromSeconds(3);
 
            var retryPolicy = Policy
                .Handle<ArgumentException>()
                .WaitAndRetryAsync(maxRetryAttempts, i => pauseBetweenFailures);
 
            await retryPolicy.ExecuteAsync(async () =>
            {
                try
                {
                    var tap = await _meIdGraphSdkManagedIdentityAppClient
                    .AddTapForUserAsync(createdUser.Id);
 
                    AccessInfo = new CreatedAccessModel
                    {
                        Email = createdUser.Email,
                        TemporaryAccessPass = tap!.TemporaryAccessPass
                    };
                }
                catch (Exception ex)
                {
                    // handle expected errors
                    if(ex.GetType() == typeof(HttpRequestException))
                        throw new ArgumentException(ex.Message);
 
                    if (ex.GetType() == typeof(Microsoft.Graph.Models.ODataErrors.ODataError))
                    { 
                        throw new ArgumentException(ex.Message);
                    }
 
                    // return 500 to UI
                    throw;
                }
            });
        }
        else
        {
            AccessInfo = new CreatedAccessModel
            {
                Email = createdUser.Email,
                Password = createdUser.Password
            };
        }
    }
}

This method creates the Graph user first, then only requests a TAP if the caller chose passwordless onboarding. The retry policy attempts the TAP call up to 20 times with a three second pause between attempts, catching the ArgumentException and Graph OData errors that show up while the new account is still propagating. Skip the retry and you will intermittently see a resource not found style error on the first attempt, purely because Entra ID has not finished writing the new object yet.

public async Task<CreatedUserModel> CreateGraphMemberUserAsync
	(UserModel userModel)
{
	if (!userModel.Email.ToLower().EndsWith(_aadIssuerDomain.ToLower()))
	{
		throw new ArgumentException("A guest user must be invited!");
	}
 
	var graphServiceClient = _graphService
		.GetGraphClientWithManagedIdentityOrDevClient();
 
	var password = GetRandomString();
	var user = new User
	{
		DisplayName = userModel.UserName,
		Surname = userModel.LastName,
		GivenName = userModel.FirstName,
		OtherMails = new List<string> { userModel.Email },
		UserType = "member",
		AccountEnabled = true,
		UserPrincipalName = userModel.Email,
		MailNickname = userModel.UserName,
		PasswordProfile = new PasswordProfile
		{
			Password = password,
			// We use TAP if a paswordless onboarding is used
			ForceChangePasswordNextSignIn = !userModel.UsePasswordless
		},
		PasswordPolicies = "DisablePasswordExpiration"
	};
 
	var createdUser = await graphServiceClient.Users
		.Request()
		.AddAsync(user);
 
	return new CreatedUserModel
	{
		Email = createdUser.UserPrincipalName,
		Id = createdUser.Id,
		Password = password
	};
}

This is the actual user creation call. The guard clause at the top throws if the email domain does not match the tenant issuer domain, because a member account can only be created for users whose UPN belongs to your verified domain. Anyone outside that domain has to go through the guest invite flow instead.

A random password still gets generated and set even for passwordless accounts. Microsoft Graph’s user creation API requires a PasswordProfile object regardless of whether you intend to use it, so the code creates one, discards it, and never surfaces it to the user. ForceChangePasswordNextSignIn is set to false when passwordless is selected, since there is no password flow to force.

public async Task<TemporaryAccessPassAuthenticationMethod?> 
	AddTapForUserAsync(string userId)
{
	var graphServiceClient = _graphService
		.GetGraphClientWithManagedIdentityOrDevClient();
 
	var tempAccessPassAuthMethod 
		= new TemporaryAccessPassAuthenticationMethod
	{
		//StartDateTime = DateTimeOffset.Now,
		LifetimeInMinutes = 60,
		IsUsableOnce = true, 
	};
 
	var result = await graphServiceClient.Users[userId]
		.Authentication
		.TemporaryAccessPassMethods
		.Request()
		.AddAsync(tempAccessPassAuthMethod);
 
	return result;
}

This call attaches the Temporary Access Pass. IsUsableOnce is set to true and LifetimeInMinutes to 60, so the pass works exactly once and expires after an hour, a sensible default for handing to a new joiner on their first day.

A pass that survives multiple redemptions is possible by setting IsUsableOnce to false, useful for a shared kiosk style onboarding scenario, but that also widens the window during which the pass could be intercepted and reused. Weigh that trade off before loosening it. The returned TemporaryAccessPass string is what gets shown in the UI, and the new user takes it to https://aka.ms/mysecurityinfolink along with their email to complete registration.

New user redeems the Temporary Access Pass at the security info link.
New user redeems the Temporary Access Pass at the security info link.

Once the user redeems the TAP at that link, Entra ID authenticates them without a password and immediately prompts them to register a strong authentication method. From here they add a FIDO2 security key.

Registering an external FIDO2 security key as the passwordless method.
Registering an external FIDO2 security key as the passwordless method.

After registration the key works immediately for sign in.

The FIDO2 key is registered and ready to use for authentication.
The FIDO2 key is registered and ready to use for authentication.

Issue at least two FIDO2 keys per user in practice, not one. A single lost or damaged key with no backup forces a full helpdesk driven account recovery, which cancels out a lot of the operational benefit of going passwordless in the first place.

TAP paired with FIDO2 is a strong pattern for enterprise onboarding. The new employee never sees or sets a password, and by the time they finish the flow they are already on a phishing resistant credential. Passkeys are converging with this model too, so this onboarding path should keep getting smoother as more devices support platform native biometric prompts.

Onboarding members with a password, the fallback path

Not everyone can go straight to FIDO2. Some organizations still run line of business apps that only understand username and password, and some staff do not carry a device capable of registering a security key. For these accounts, the Graph call sets ForceChangePasswordNextSignIn to true, and the user updates the password on first sign in, then registers MFA if the tenant does not already force it.

Phone based authentication setup for users without FIDO2 capable devices.
Phone based authentication setup for users without FIDO2 capable devices.

Phone based accounts using email code or SMS deserve deliberate product level treatment rather than an afterthought. Treat them as lower trust by default and restrict what data or actions they can reach inside your applications. If device code flow is your intended path for a shared PC scenario, remember that starting the flow from a QR code is a known phishing vector, and stacking that on top of SMS, which is already weak, compounds the risk. This is a trade off every organization needs to make on purpose rather than by default configuration.

Onboarding guest users through invitations

Guests are handled differently in the Graph API. A guest is never created as a Graph User object directly, an invitation gets sent instead. The invitation email carries a redeem link, so unlike the member flow there is nothing to display in your own UI, the user completes setup by clicking the link in their inbox.

private async Task InviteGuest(UserModel userData)
{
	var invitedGuestUser = await _aadGraphSdkManagedIdentityAppClient
					.InviteGuestUser(userData, _inviteUrl);
 
	if (invitedGuestUser!.Id != null)
	{
		AccessInfo = new CreatedAccessModel
		{
			Email = invitedGuestUser.InvitedUserEmailAddress,
			InviteRedeemUrl = invitedGuestUser.InviteRedeemUrl
		};
	}
}

This is the calling method inside the app. It forwards the request and redirect URL to the Graph wrapper and stores the resulting invite metadata for the UI, which shows the invited user’s email once the call succeeds.

public async Task<Invitation?> InviteGuestUser
	(UserModel userModel, string redirectUrl)
{
	if (userModel.Email.ToLower().EndsWith(_aadIssuerDomain.ToLower()))
	{
		throw new ArgumentException("user must be from a different domain!");
	}
 
	var graphServiceClient = _graphService
		.GetGraphClientWithManagedIdentityOrDevClient();
 
	var invitation = new Invitation
	{
		InvitedUserEmailAddress = userModel.Email,
		SendInvitationMessage = true,
		InvitedUserDisplayName 
			= $"{userModel.FirstName} {userModel.LastName}",
		InviteRedirectUrl = redirectUrl,
		InvitedUserType = "guest"
	};
 
	var invite = await graphServiceClient.Invitations
		.Request()
		.AddAsync(invitation);
 
	return invite;
}

The domain check here mirrors the member flow in reverse. It throws if the email domain does match your tenant, because guests by definition come from outside your organization. InvitedUserType is set to guest and SendInvitationMessage to true, so Entra ID handles delivering the invite email itself, and you do not need to build your own notification pipeline for this path.

Practical notes for production use

A few things worth knowing before you build on this pattern. The workflow depends on TAP and parts of the Graph authentication methods API that were still in beta when this was written. Beta Graph endpoints can change shape between releases, so pin your SDK version and test carefully after every Microsoft.Identity.Web.MicrosoftGraphBeta upgrade rather than treating it as a routine bump.

The sample also does not attempt idempotency. If the retry policy in CreateMember exhausts all 20 attempts, or the process crashes mid flow, you can end up with a member user that has no TAP attached and no clean way to detect that state from outside. A production version of this would persist onboarding state somewhere durable, a queue or a database row per invite, so a failed run can be retried or reconciled without someone digging through Graph manually.

Keep the Graph application permissions in a dedicated app registration, separate from any other backend service. If this credential leaks, the blast radius is every identity in the tenant, a very different risk profile from a typical API’s leaked secret.

Leave a Reply

Discover more from Behind the Stack

Subscribe now to keep reading and get access to the full archive.

Continue reading