IdentityEmailMVCAugust 2026 · 8 min read

Email Confirmation and Password Reset with ASP.NET Core Identity

TL;DR

Email confirmation verifies the user owns their inbox; password reset lets them recover access securely. Both use Identity's built-in token providers, so you only build the delivery layer: generate the token, send it via SendGrid or SMTP, and process the callback in the AccountController.

Two email flows matter more than any other in an ASP.NET Core application: confirming a new account and resetting a forgotten password. Both rely on ASP.NET Core Identity token providers, which generate single-use, time-limited codes. This article builds both flows end to end in .NET 10 — registration with confirmation, forgot password, the reset page, and the email delivery layer behind SendGrid or SMTP.

The Identity Token Providers

Identity generates confirmation and reset tokens with the default token providers registered by AddDefaultTokenProviders(). The important properties: tokens are single-use, scoped to a purpose (email confirmation vs password reset), and time-limited. If RequireConfirmedEmail = true, Identity will reject login until the account is confirmed:

builder.Services
    .AddIdentity<AppUser, IdentityRole>(options =>
    {
        options.SignIn.RequireConfirmedEmail = true;
    })
    .AddEntityFrameworkStores<AppDbContext>()
    .AddDefaultTokenProviders();

Registration with Email Confirmation

Create the user without signing them in, generate the confirmation token, and email a link that confirms the account:

var user = new AppUser { UserName = model.Email, Email = model.Email, FullName = model.FullName };
var result = await _userManager.CreateAsync(user, model.Password);
if (!result.Succeeded) { /* surface errors */ }

var token = await _userManager.GenerateEmailConfirmationTokenAsync(user);
var callback = Url.Action("ConfirmEmail", "Account",
    new { userId = user.Id, token }, Request.Scheme);

await _emailSender.SendAsync(
    to: user.Email,
    subject: "Confirm your email",
    body: "Click here to confirm your account.");

return RedirectToAction("CheckEmail");

The ConfirmEmail Action

The callback validates the token and flips the flag. The token is purpose-scoped, so a password-reset token cannot be used here:

[HttpGet]
public async Task<IActionResult> ConfirmEmail(string userId, string token)
{
    if (string.IsNullOrEmpty(userId) || string.IsNullOrEmpty(token))
        return BadRequest("Invalid email confirmation link.");

    var user = await _userManager.FindByIdAsync(userId);
    if (user is null) return NotFound();

    var result = await _userManager.ConfirmEmailAsync(user, token);
    return result.Succeeded ? View("ConfirmEmailDone") : BadRequest("Invalid or expired token.");
}

Forgot Password and Reset

Forgot-password generates a reset token and emails a link. Reset validates it and changes the password atomically:

[HttpPost]
public async Task<IActionResult> ForgotPassword(ForgotPasswordViewModel model)
{
    var user = await _userManager.FindByEmailAsync(model.Email);
    if (user is null || !await _userManager.IsEmailConfirmedAsync(user))
        return RedirectToAction("ForgotPasswordConfirmation"); // always succeed

    var token = await _userManager.GeneratePasswordResetTokenAsync(user);
    var callback = Url.Action("ResetPassword", "Account",
        new { email = model.Email, token }, Request.Scheme);
    await _emailSender.SendAsync(user.Email, "Reset your password",
        "Click here to reset your password.");
    return RedirectToAction("ForgotPasswordConfirmation");
}

[HttpPost]
public async Task<IActionResult> ResetPassword(ResetPasswordViewModel model)
{
    if (!ModelState.IsValid) return View(model);
    var user = await _userManager.FindByEmailAsync(model.Email);
    if (user is null) return RedirectToAction("ResetPasswordConfirmation");

    var result = await _userManager.ResetPasswordAsync(user, model.Token, model.Password);
    if (!result.Succeeded) { foreach (var e in result.Errors) ModelState.AddModelError("", e.Description); return View(model); }
    return RedirectToAction("ResetPasswordConfirmation");
}

Returning the same confirmation view whether or not the email exists prevents user enumeration — an attacker cannot discover which addresses are registered.

The Email Delivery Layer

Abstract sending behind an interface so you can swap SendGrid, Mailgun, SMTP, or Mailjet without touching the controller:

public interface IEmailSender
{
    Task SendAsync(string to, string subject, string body);
}

public class SendGridEmailSender : IEmailSender
{
    private readonly SendGridClient _client;
    public SendGridEmailSender(IConfiguration config)
        => _client = new SendGridClient(config["SendGrid:ApiKey"]);

    public async Task SendAsync(string to, string subject, string body)
    {
        var message = MailHelper.CreateSingleEmail(
            new EmailAddress("no-reply@indotalent.com", "Indotalent"),
            new EmailAddress(to), subject, "", body);
        await _client.SendEmailAsync(message);
    }
}

The controller depends only on IEmailSender, so switching providers is a DI registration change. This is the same provider abstraction the MVC EDevKit Basic starter uses for its SendGrid/Mailgun/SMTP/Mailjet support.

Key Takeaways

  • Identity token providers make confirmation and reset tokens single-use and time-limited
  • RequireConfirmedEmail blocks login until the account is verified
  • Always return the same response when an email does not exist to prevent enumeration
  • Abstract email delivery behind IEmailSender to swap providers freely
  • Reset tokens are purpose-scoped and cannot be reused for confirmation

FAQ

How long is an Identity token valid? The default token lifetime is 24 hours, and tokens are single-use. Both confirmation and reset tokens expire automatically.

Do I need email confirmation for production? It is strongly recommended. Confirmed emails prevent sign-ups with fake addresses and gate features that depend on verified identity.

Can I skip email confirmation for Google SSO users? Yes. The email is already verified by Google during the Firebase SSO flow, so those accounts can be marked confirmed at creation.

Should the reset link expire? Yes, and Identity does this for you. The token provider embeds the generation time and rejects tokens older than the configured lifetime.

Study the complete Identity email flows in real MVC code?

MVC EDevKit Basic ships email confirmation, password reset, and multi-provider email delivery (SendGrid, Mailgun, SMTP, Mailjet) integrated with Identity. $21.

View MVC EDevKit Details