aspnetcore/blazor/security/webassembly/standalone-with-identity/account-confirmation-and-password-recovery.md
This article explains how to configure an ASP.NET Core Blazor WebAssembly app with ASP.NET Core Identity with email confirmation and password recovery.
[!NOTE] This article only applies standalone Blazor WebAssembly apps with ASP.NET Core Identity. To implement email confirmation and password recovery for Blazor Web Apps, see xref:blazor/security/account-confirmation-and-password-recovery.
The namespaces used by the examples in this article are:
Backend for the backend server web API project ("server project" in this article).BlazorWasmAuth for the front-end client standalone Blazor WebAssembly app ("client project" in this article).These namespaces correspond to the projects in the BlazorWebAssemblyStandaloneWithIdentity sample solution in the dotnet/blazor-samples GitHub repository. For more information, see xref:blazor/security/webassembly/standalone-with-identity/index#sample-apps.
If you aren't using the BlazorWebAssemblyStandaloneWithIdentity sample solution, change the namespaces in the code examples to use the namespaces of your projects.
In this article, Mailchimp's Transactional API is used via Mandrill.net to send email. We recommend using an email service to send email rather than SMTP. SMTP is difficult to configure and secure properly. Whichever email service you use, access their guidance for .NET apps, create an account, configure an API key for their service, and install any NuGet packages required.
In the server project, create a class to hold the secret email provider API key. The example in this article uses a class named AuthMessageSenderOptions with an EmailAuthKey property to hold the key.
AuthMessageSenderOptions.cs:
namespace Backend;
public class AuthMessageSenderOptions
{
public string? EmailAuthKey { get; set; }
}
Register the AuthMessageSenderOptions configuration instance in the server project's Program file:
builder.Services.Configure<AuthMessageSenderOptions>(builder.Configuration);
Receive the email provider's security key from the provider and use it in the following guidance.
Use either or both of the following approaches to supply the secret to the app:
Development environment when working locally. Some developers prefer to use key vaults for staging and production deployments and use the Secret Manager tool for local development.We strongly recommend that you avoid storing secrets in project code or configuration files. Use secure authentication flows, such as either or both of the approaches in this section.
If the server project has already been initialized for the Secret Manager tool, it will already have a user secrets identifier (<UserSecretsId>) in its project file (.csproj). In Visual Studio, you can tell if the user secrets ID is present by looking at the Properties panel when the project is selected in Solution Explorer. If the app hasn't been initialized, execute the following command in a command shell opened to the server project's directory. In Visual Studio, you can use the Developer PowerShell command prompt (use the cd command to change the directory to the server project after you open the command shell).
dotnet user-secrets init
Set the API key with the Secret Manager tool. In the following example, the key name is EmailAuthKey to match AuthMessageSenderOptions.EmailAuthKey, and the key is represented by the {KEY} placeholder. Execute the following command with the API key:
dotnet user-secrets set "EmailAuthKey" "{KEY}"
If using Visual Studio, you can confirm the secret is set by right-clicking the server project in Solution Explorer and selecting Manage User Secrets.
For more information, see xref:security/app-secrets.
Azure Key Vault provides a safe approach for providing the app's client secret to the app.
To create a key vault and set a secret, see About Azure Key Vault secrets (Azure documentation), which cross-links resources to get started with Azure Key Vault. To implement the code in this section, record the key vault URI and the secret name from Azure when you create the key vault and secret. When you set the access policy for the secret in the Access policies panel:
Confirm in the Azure or Entra portal that the app has been granted access to the secret that you created for the email provider key.
[!IMPORTANT] A key vault secret is created with an expiration date. Be sure to track when a key vault secret is going to expire and create a new secret for the app prior to that date passing.
If Microsoft Identity packages aren't already part of the app's package registrations, add the following packages to the server project for Azure Identity and Azure Key Vault. These packages are transitively provided by Microsoft Identity Web packages, so you only need to add them if the app isn't referencing Microsoft.Identity.Web:
Add the following AzureHelper class to the server project. The GetKeyVaultSecret method retrieves a secret from a key vault. Adjust the namespace (BlazorSample.Helpers) to match your project namespace scheme.
Helpers/AzureHelper.cs:
using Azure.Identity;
using Azure.Security.KeyVault.Secrets;
namespace BlazorSample.Helpers;
public static class AzureHelper
{
public static string GetKeyVaultSecret(string tenantId, string vaultUri, string secretName)
{
DefaultAzureCredentialOptions options = new()
{
// Specify the tenant ID to use the dev credentials when running the app locally
// in Visual Studio.
VisualStudioTenantId = tenantId,
SharedTokenCacheTenantId = tenantId
};
var client = new SecretClient(new Uri(vaultUri), new DefaultAzureCredential(options));
var secret = client.GetSecretAsync(secretName).Result;
return secret.Value.Value;
}
}
[!NOTE] The preceding example uses xref:Azure.Identity.DefaultAzureCredential to simplify authentication while developing apps that deploy to Azure by combining credentials used in Azure hosting environments with credentials used in local development. When moving to production, an alternative is a better choice, such as xref:Azure.Identity.ManagedIdentityCredential. For more information, see Authenticate Azure-hosted .NET apps to Azure resources using a system-assigned managed identity.
Where services are registered in the server project's Program file, obtain and bind the secret with Options configuration:
var tenantId = builder.Configuration.GetValue<string>("AzureAd:TenantId")!;
var vaultUri = builder.Configuration.GetValue<string>("AzureAd:VaultUri")!;
var emailAuthKey = AzureHelper.GetKeyVaultSecret(
tenantId, vaultUri, "EmailAuthKey");
var authMessageSenderOptions =
new AuthMessageSenderOptions() { EmailAuthKey = emailAuthKey };
builder.Configuration.GetSection(authMessageSenderOptions.EmailAuthKey)
.Bind(authMessageSenderOptions);
If you wish to control the environment where the preceding code operates, for example to avoid running the code locally because you've opted to use the Secret Manager tool for local development, you can wrap the preceding code in a conditional statement that checks the environment:
if (!context.HostingEnvironment.IsDevelopment())
{
...
}
In the AzureAd section, which you may need to add if it isn't already present, of appsettings.json in the server project, add the following TenantId and VaultUri configuration keys and values, if they aren't already present:
"AzureAd": {
"TenantId": "{TENANT ID}",
"VaultUri": "{VAULT URI}"
}
In the preceding example:
{TENANT ID} placeholder is the app's tenant ID in Azure.{VAULT URI} placeholder is the key vault URI. Include the trailing slash on the URI.Example:
"TenantId": "00001111-aaaa-2222-bbbb-3333cccc4444",
"VaultUri": "https://contoso.vault.azure.net/"
Configuration is used to facilitate supplying dedicated key vaults and secret names based on the app's environmental configuration files. For example, you can supply different configuration values for appsettings.Development.json in development, appsettings.Staging.json when staging, and appsettings.Production.json for the production deployment. For more information, see xref:blazor/fundamentals/configuration.
IEmailSender in the server projectThe following example is based on Mailchimp's Transactional API using Mandrill.net. For a different provider, refer to their documentation on how to implement sending an email message.
Add the Mandrill.net NuGet package to the server project.
Add the following EmailSender class to implement xref:Microsoft.AspNetCore.Identity.IEmailSender%601. In the following example, AppUser is an xref:Microsoft.AspNetCore.Identity.IdentityUser. The message HTML markup can be further customized. As long as the message passed to MandrillMessage starts with the < character, the Mandrill.net API assumes that the message body is composed in HTML.
EmailSender.cs:
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;
using Mandrill;
using Mandrill.Model;
namespace Backend;
public class EmailSender(IOptions<AuthMessageSenderOptions> optionsAccessor,
ILogger<EmailSender> logger) : IEmailSender<AppUser>
{
private readonly ILogger logger = logger;
public AuthMessageSenderOptions Options { get; } = optionsAccessor.Value;
public Task SendConfirmationLinkAsync(AppUser user, string email,
string confirmationLink) => SendEmailAsync(email, "Confirm your email",
"<html lang=\"en\"><head></head><body>Please confirm your account by " +
$"<a href='{confirmationLink}'>clicking here</a>.</body></html>");
public Task SendPasswordResetLinkAsync(AppUser user, string email,
string resetLink) => SendEmailAsync(email, "Reset your password",
"<html lang=\"en\"><head></head><body>Please reset your password by " +
$"<a href='{resetLink}'>clicking here</a>.</body></html>");
public Task SendPasswordResetCodeAsync(AppUser user, string email,
string resetCode) => SendEmailAsync(email, "Reset your password",
"<html lang=\"en\"><head></head><body>Please reset your password " +
$"using the following code:
{resetCode}</body></html>");
public async Task SendEmailAsync(string toEmail, string subject, string message)
{
if (string.IsNullOrEmpty(Options.EmailAuthKey))
{
throw new Exception("Null EmailAuthKey");
}
await Execute(Options.EmailAuthKey, subject, message, toEmail);
}
public async Task Execute(string apiKey, string subject, string message,
string toEmail)
{
var api = new MandrillApi(apiKey);
var mandrillMessage = new MandrillMessage("[email protected]", toEmail,
subject, message);
await api.Messages.SendAsync(mandrillMessage);
logger.LogInformation("Email to {EmailAddress} sent!", toEmail);
}
}
[!NOTE] Body content for messages might require special encoding for the email service provider. If links in the message body can't be followed in the email message, consult the service provider's documentation to troubleshoot the problem.
Add the following xref:Microsoft.AspNetCore.Identity.IEmailSender%601 service registration to the server project's Program file:
builder.Services.AddTransient<IEmailSender<AppUser>, EmailSender>();
In the server project's Program file, require a confirmed email address to sign in to the app.
Locate the line that calls xref:Microsoft.Extensions.DependencyInjection.IdentityServiceCollectionExtensions.AddIdentityCore%2A and set the xref:Microsoft.AspNetCore.Identity.SignInOptions.RequireConfirmedEmail property to true:
- builder.Services.AddIdentityCore<AppUser>()
+ builder.Services.AddIdentityCore<AppUser>(o => o.SignIn.RequireConfirmedEmail = true)
In the client project's Register component (Components/Identity/Register.razor), change the message to users on a successful account registration to instruct them to confirm their account. The following example includes a link to trigger Identity on the server to resend the confirmation email.
- You successfully registered and can <a href="login">login</a> to the app.
+ You successfully registered. You must now confirm your account by clicking
+ the link in the email that was sent to you. After confirming your account,
+ you can <a href="login">login</a> to the app.
+ <a href="resendConfirmationEmail">Resend confirmation email</a>
In the server project's seed data class (SeedData.cs), change the code in the InitializeAsync method to confirm the seeded accounts, which avoids requiring email address confirmation for each test run of the solution with one of the accounts:
- if (appUser is not null && user.RoleList is not null)
- {
- await userManager.AddToRolesAsync(appUser, user.RoleList);
- }
+ if (appUser is not null)
+ {
+ if (user.RoleList is not null)
+ {
+ await userManager.AddToRolesAsync(appUser, user.RoleList);
+ }
+
+ var token = await userManager.GenerateEmailConfirmationTokenAsync(appUser);
+ await userManager.ConfirmEmailAsync(appUser, token);
+ }
Enabling account confirmation on a site with users locks out all the existing users. Existing users are locked out because their accounts aren't confirmed. Use one of the following approaches, which are beyond the scope of this article:
Password recovery requires the server app to adopt an email provider in order to send password reset codes to users. Therefore, follow the guidance earlier in this article to adopt an email provider:
IEmailSender in the server projectPassword recovery is a two-step process:
/forgotPassword endpoint provided by xref:Microsoft.AspNetCore.Routing.IdentityApiEndpointRouteBuilderExtensions.MapIdentityApi%2A in the server project. A message in the UI instructs the user to check their email for a reset code./resetPassword endpoint of the server project with the user's email address, password reset code, and new password.The preceding steps are demonstrated by the following implementation guidance for the sample solution.
In the client project, add the following method signatures to the IAccountManagement class (Identity/IAccountManagement.cs):
public Task<bool> ForgotPasswordAsync(string email);
public Task<FormResult> ResetPasswordAsync(string email, string resetCode,
string newPassword);
In the client project, add implementations for the preceding methods in the CookieAuthenticationStateProvider class (Identity/CookieAuthenticationStateProvider.cs):
public async Task<bool> ForgotPasswordAsync(string email)
{
try
{
using var result = await httpClient.PostAsJsonAsync(
"forgotPassword", new
{
email
});
if (result.IsSuccessStatusCode)
{
return true;
}
}
catch { }
return false;
}
public async Task<FormResult> ResetPasswordAsync(string email, string resetCode,
string newPassword)
{
string[] defaultDetail = [ "An unknown error prevented password reset." ];
try
{
using var result = await httpClient.PostAsJsonAsync(
"resetPassword", new
{
email,
resetCode,
newPassword
});
if (result.IsSuccessStatusCode)
{
return new FormResult { Succeeded = true };
}
var details = await result.Content.ReadAsStringAsync();
var problemDetails = JsonDocument.Parse(details);
var errors = new List<string>();
var errorList = problemDetails.RootElement.GetProperty("errors");
foreach (var errorEntry in errorList.EnumerateObject())
{
if (errorEntry.Value.ValueKind == JsonValueKind.String)
{
errors.Add(errorEntry.Value.GetString()!);
}
else if (errorEntry.Value.ValueKind == JsonValueKind.Array)
{
errors.AddRange(
errorEntry.Value.EnumerateArray().Select(
e => e.GetString() ?? string.Empty)
.Where(e => !string.IsNullOrEmpty(e)));
}
}
return new FormResult
{
Succeeded = false,
ErrorList = problemDetails == null ? defaultDetail : [.. errors]
};
}
catch { }
return new FormResult
{
Succeeded = false,
ErrorList = defaultDetail
};
}
In the client project, add the following ForgotPassword component.
Components/Identity/ForgotPassword.razor:
@page "/forgot-password"
@using System.ComponentModel.DataAnnotations
@using BlazorWasmAuth.Identity
@inject IAccountManagement Acct
<PageTitle>Forgot your password?</PageTitle>
<h1>Forgot your password?</h1>
<p>Provide your email address and select the <b>Reset password</b> button.</p>
<hr />
<div class="row">
<div class="col-md-4">
@if (!passwordResetCodeSent)
{
<EditForm Model="Input" FormName="forgot-password"
OnValidSubmit="OnValidSubmitStep1Async" method="post">
<DataAnnotationsValidator />
<ValidationSummary class="text-danger" role="alert" />
<div class="form-floating mb-3">
<InputText @bind-Value="Input.Email"
id="Input.Email" class="form-control"
autocomplete="username" aria-required="true"
placeholder="[email protected]" />
<label for="Input.Email" class="form-label">
Email
</label>
<ValidationMessage For="() => Input.Email"
class="text-danger" />
</div>
<button type="submit" class="w-100 btn btn-lg btn-primary">
Request reset code
</button>
</EditForm>
}
else
{
if (passwordResetSuccess)
{
if (errors)
{
foreach (var error in errorList)
{
<div class="alert alert-danger">@error</div>
}
}
else
{
<div>
Your password was reset. You may <a href="login">login</a>
to the app with your new password.
</div>
}
}
else
{
<div>
A password reset code has been sent to your email address.
Obtain the code from the email for this form.
</div>
<EditForm Model="Reset" FormName="reset-password"
OnValidSubmit="OnValidSubmitStep2Async" method="post">
<DataAnnotationsValidator />
<ValidationSummary class="text-danger" role="alert" />
<div class="form-floating mb-3">
<InputText @bind-Value="Reset.ResetCode"
id="Reset.ResetCode" class="form-control"
autocomplete="username" aria-required="true" />
<label for="Reset.ResetCode" class="form-label">
Reset code
</label>
<ValidationMessage For="() => Reset.ResetCode"
class="text-danger" />
</div>
<div class="form-floating mb-3">
<InputText type="password" @bind-Value="Reset.NewPassword"
id="Reset.NewPassword" class="form-control"
autocomplete="new-password" aria-required="true"
placeholder="password" />
<label for="Reset.NewPassword" class="form-label">
New Password
</label>
<ValidationMessage For="() => Reset.NewPassword"
class="text-danger" />
</div>
<div class="form-floating mb-3">
<InputText type="password"
@bind-Value="Reset.ConfirmPassword"
id="Reset.ConfirmPassword" class="form-control"
autocomplete="new-password" aria-required="true"
placeholder="password" />
<label for="Reset.ConfirmPassword" class="form-label">
Confirm Password
</label>
<ValidationMessage For="() => Reset.ConfirmPassword"
class="text-danger" />
</div>
<button type="submit" class="w-100 btn btn-lg btn-primary">
Reset password
</button>
</EditForm>
}
}
</div>
</div>
@code {
private bool passwordResetCodeSent, passwordResetSuccess, errors;
private string[] errorList = [];
[SupplyParameterFromForm(FormName = "forgot-password")]
private InputModel Input { get; set; } = new();
[SupplyParameterFromForm(FormName = "reset-password")]
private ResetModel Reset { get; set; } = new();
private async Task OnValidSubmitStep1Async()
{
passwordResetCodeSent = await Acct.ForgotPasswordAsync(Input.Email);
}
private async Task OnValidSubmitStep2Async()
{
var result = await Acct.ResetPasswordAsync(Input.Email, Reset.ResetCode,
Reset.NewPassword);
if (result.Succeeded)
{
passwordResetSuccess = true;
}
else
{
errors = true;
errorList = result.ErrorList;
}
}
private sealed class InputModel
{
[Required]
[EmailAddress]
[Display(Name = "Email")]
public string Email { get; set; } = string.Empty;
}
private sealed class ResetModel
{
[Required]
[Base64String]
public string ResetCode { get; set; } = string.Empty;
[StringLength(100, ErrorMessage = "The {0} must be at least {2} and at " +
"max {1} characters long.", MinimumLength = 6)]
[DataType(DataType.Password)]
[Display(Name = "Password")]
public string NewPassword { get; set; } = string.Empty;
[DataType(DataType.Password)]
[Display(Name = "Confirm password")]
[Compare("NewPassword", ErrorMessage = "The new password and confirmation " +
"password don't match.")]
public string ConfirmPassword { get; set; } = string.Empty;
}
}
In the Login component (Components/Identity/Login.razor) of the client project immediately before the closing </NotAuthorized> tag, add a forgot password link to reach the ForgotPassword component:
<div>
<a href="forgot-password">Forgot password</a>
</div>
The default inactivity timeout is 14 days. In the server project, the following code sets the inactivity timeout to five days with sliding expiration:
builder.Services.ConfigureApplicationCookie(options => {
options.ExpireTimeSpan = TimeSpan.FromDays(5);
options.SlidingExpiration = true;
});
In the server project, the following code changes Data Protection tokens' timeout period to three hours:
builder.Services.Configure<DataProtectionTokenProviderOptions>(options =>
options.TokenLifespan = TimeSpan.FromHours(3));
The built-in Identity user tokens (AspNetCore/src/Identity/Extensions.Core/src/TokenOptions.cs) have a one day timeout.
The default token lifespan of the Identity user tokens is one day.
To change the email token lifespan, add a custom xref:Microsoft.AspNetCore.Identity.DataProtectorTokenProvider%601 and xref:Microsoft.AspNetCore.Identity.DataProtectionTokenProviderOptions to the server project.
CustomTokenProvider.cs:
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;
namespace BlazorSample;
public class CustomEmailConfirmationTokenProvider<TUser>
: DataProtectorTokenProvider<TUser> where TUser : class
{
public CustomEmailConfirmationTokenProvider(
IDataProtectionProvider dataProtectionProvider,
IOptions<EmailConfirmationTokenProviderOptions> options,
ILogger<DataProtectorTokenProvider<TUser>> logger)
: base(dataProtectionProvider, options, logger)
{
}
}
public class EmailConfirmationTokenProviderOptions
: DataProtectionTokenProviderOptions
{
public EmailConfirmationTokenProviderOptions()
{
Name = "EmailDataProtectorTokenProvider";
TokenLifespan = TimeSpan.FromHours(4);
}
}
public class CustomPasswordResetTokenProvider<TUser>
: DataProtectorTokenProvider<TUser> where TUser : class
{
public CustomPasswordResetTokenProvider(
IDataProtectionProvider dataProtectionProvider,
IOptions<PasswordResetTokenProviderOptions> options,
ILogger<DataProtectorTokenProvider<TUser>> logger)
: base(dataProtectionProvider, options, logger)
{
}
}
public class PasswordResetTokenProviderOptions :
DataProtectionTokenProviderOptions
{
public PasswordResetTokenProviderOptions()
{
Name = "PasswordResetDataProtectorTokenProvider";
TokenLifespan = TimeSpan.FromHours(3);
}
}
Configure the services to use the custom token provider in the server project's Program file:
builder.Services.AddIdentityCore<AppUser>(options =>
{
options.SignIn.RequireConfirmedAccount = true;
options.Tokens.ProviderMap.Add("CustomEmailConfirmation",
new TokenProviderDescriptor(
typeof(CustomEmailConfirmationTokenProvider<AppUser>)));
options.Tokens.EmailConfirmationTokenProvider =
"CustomEmailConfirmation";
})
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddSignInManager()
.AddDefaultTokenProviders();
builder.Services
.AddTransient<CustomEmailConfirmationTokenProvider<AppUser>>();
If you can't get email working:
EmailSender.Execute to verify SendEmailAsync is called.EmailSender.Execute to debug the problem.