Email Confirmation and Password Reset

๐Ÿ“‹ Table of Contents โ–พ
  1. Email Confirmation
  2. Password Reset
  3. Common Mistakes

Email confirmation and password reset are security requirements for any production application. Email confirmation verifies that the user owns the email address they registered with โ€” preventing account takeover via email typos and reducing spam registrations. Password reset provides a secure way for users to regain access when they forget their password. Both workflows use ASP.NET Core Identity’s Data Protection API-backed token generation โ€” time-limited, cryptographically secure tokens sent by email.

Email Confirmation

// โ”€โ”€ Step 1: Generate confirmation token on registration โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> Register(RegisterViewModel model)
{
    if (!ModelState.IsValid) return View(model);

    var user = new ApplicationUser { UserName = model.Email, Email = model.Email };
    var result = await userManager.CreateAsync(user, model.Password);

    if (!result.Succeeded)
    {
        foreach (var err in result.Errors) ModelState.AddModelError("", err.Description);
        return View(model);
    }

    // Generate a time-limited confirmation token (backed by Data Protection API)
    var token = await userManager.GenerateEmailConfirmationTokenAsync(user);

    // URL-encode the token (it may contain special characters)
    var callbackUrl = Url.Action(
        nameof(ConfirmEmail), "Account",
        new { userId = user.Id, token = token },
        protocol: Request.Scheme,
        host: Request.Host.Value)!;

    await emailSender.SendAsync(
        model.Email,
        "Confirm your email",
        $"Please confirm your account by clicking: <a href='{callbackUrl}'>Confirm Email</a>");

    return View("RegisterConfirmation");  // "Check your email" page
}

// โ”€โ”€ Step 2: Confirm email when user clicks the link โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
[HttpGet, AllowAnonymous]
public async Task<IActionResult> ConfirmEmail(string userId, string token)
{
    var user = await userManager.FindByIdAsync(userId);
    if (user is null) return NotFound("User not found.");

    var result = await userManager.ConfirmEmailAsync(user, token);

    return result.Succeeded
        ? View("ConfirmEmailSuccess")
        : View("ConfirmEmailError");
}
Note: The email confirmation token contains a DataProtection-backed cryptographic value. Tokens have a default lifetime of 1 day (configurable with options.Tokens.ProviderMap). If the user does not click the link within this window, the token expires and the confirmation fails. Always implement a “Resend confirmation email” feature for this case โ€” the user requests a new token and a new email is sent. Check user.EmailConfirmed first to avoid resending to already-confirmed users.
Tip: URL-encode the token before including it in the confirmation link โ€” tokens from GenerateEmailConfirmationTokenAsync often contain characters like +, /, and = that have special meaning in URLs. Use WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(token)) for encoding and Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(encodedToken)) for decoding. ASP.NET Core’s Url.Action() performs URL encoding automatically when you pass the token as a route value, but double-check by testing with tokens that contain these characters.
Warning: Never show the token in the response body or logs. The token is a one-time-use credential โ€” anyone who intercepts it can use it to confirm an email or reset a password. Tokens should only be transmitted via the confirmation email link. Do not log the callbackUrl at any level above Debug, and never include tokens in error messages shown to users.

Password Reset

// โ”€โ”€ Forgot Password โ€” request a reset link โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> ForgotPassword(ForgotPasswordViewModel model)
{
    if (!ModelState.IsValid) return View(model);

    // Always return the same response regardless of whether the email exists
    // (prevents user enumeration โ€” attacker cannot tell if email is registered)
    var user = await userManager.FindByEmailAsync(model.Email);

    if (user is not null && await userManager.IsEmailConfirmedAsync(user))
    {
        var token = await userManager.GeneratePasswordResetTokenAsync(user);
        var resetUrl = Url.Action(
            nameof(ResetPassword), "Account",
            new { userId = user.Id, token },
            protocol: "https")!;

        await emailSender.SendAsync(
            model.Email, "Reset your password",
            $"Reset your password: <a href='{resetUrl}'>Reset Password</a>");
    }

    // ALWAYS show this page โ€” never reveal if email is registered
    return View("ForgotPasswordConfirmation");
}

// โ”€โ”€ Reset Password โ€” use the token from the email link โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> ResetPassword(ResetPasswordViewModel model)
{
    if (!ModelState.IsValid) return View(model);

    var user = await userManager.FindByIdAsync(model.UserId);
    if (user is null)
        return View("ResetPasswordConfirmation");  // silent โ€” user not found

    var result = await userManager.ResetPasswordAsync(user, model.Token, model.NewPassword);

    if (!result.Succeeded)
    {
        foreach (var err in result.Errors) ModelState.AddModelError("", err.Description);
        return View(model);
    }

    return View("ResetPasswordConfirmation");
}

Common Mistakes

Mistake 1 โ€” Revealing whether an email is registered on forgot password (user enumeration)

โŒ Wrong โ€” “We sent a reset email to this address” only when email exists; “Email not found” otherwise.

โœ… Correct โ€” always show “If that email is registered, you’ll receive a reset link” regardless.

โŒ Wrong โ€” token with + characters is treated as a space in the URL; token validation fails.

โœ… Correct โ€” let Url.Action() encode the token as a route value, or manually Base64Url-encode it.

🧠 Test Yourself

The ForgotPassword action always returns “ForgotPasswordConfirmation” regardless of whether the email exists. Why is this the correct behaviour?