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");
}
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.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.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.
Mistake 2 โ Not URL-encoding the token in the confirmation link (broken links)
โ 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.