Introduction

ASP.NET Core Identity can be configured to require email confirmation before allowing login via options.SignIn.RequireConfirmedAccount = true. When this is enabled, users with unconfirmed email addresses receive a NotAllowed sign-in result even with correct credentials. Common issues include confirmation emails never being sent, confirmation tokens expiring or being invalidated, the email sender not being registered, or users being unable to request a new confirmation email. Proper setup requires a working IEmailSender, valid token generation, and a user-friendly resend flow.

Symptoms

  • SignInResult.NotAllowed returned for valid credentials
  • User can register but cannot log in
  • Confirmation email never arrives
  • Confirmation link shows "invalid token" error
  • User stuck in unconfirmed state with no way to re-send email
  • Account locked out after failed login attempts before confirmation

Error output: ```csharp var result = await _signInManager.PasswordSignInAsync( model.Email, model.Password, model.RememberMe, lockoutOnFailure: true);

// result.Succeeded = false // result.IsNotAllowed = true <-- Email not confirmed // result.IsLockedOut = false ```

Common Causes

  • RequireConfirmedAccount enabled without implementing email sender
  • IEmailSender not registered in DI container
  • Confirmation token generated with wrong user ID or provider
  • Data protection keys not persisted, invalidating tokens across restarts
  • Email confirmation URL not encoded correctly
  • User confirmed in database but sign-in manager not picking up change

Step-by-Step Fix

  1. 1.Configure Identity with email confirmation and email sender:
  2. 2.```csharp
  3. 3.// Program.cs
  4. 4.builder.Services.AddDefaultIdentity<ApplicationUser>(options =>
  5. 5.{
  6. 6.options.SignIn.RequireConfirmedAccount = true; // Require email confirmation
  7. 7.options.Password.RequireDigit = true;
  8. 8.options.Password.RequiredLength = 8;
  9. 9.options.User.RequireUniqueEmail = true;
  10. 10.})
  11. 11..AddEntityFrameworkStores<ApplicationDbContext>()
  12. 12..AddDefaultTokenProviders(); // Required for email confirmation tokens

// Register email sender builder.Services.AddTransient<IEmailSender, EmailSender>(); builder.Services.Configure<AuthMessageSenderOptions>( builder.Configuration.GetSection("EmailSettings"));

// Or use a third-party email confirmation handler builder.Services.AddIdentityCore<ApplicationUser>(options => { options.SignIn.RequireConfirmedAccount = true; }) .AddTokenProvider<EmailConfirmationTokenProvider<ApplicationUser>>("EmailConfirmation") .AddEntityFrameworkStores<ApplicationDbContext>(); ```

  1. 1.Implement email confirmation flow with proper token handling:
  2. 2.```csharp
  3. 3.public class AccountController : Controller
  4. 4.{
  5. 5.private readonly UserManager<ApplicationUser> _userManager;
  6. 6.private readonly SignInManager<ApplicationUser> _signInManager;
  7. 7.private readonly IEmailSender _emailSender;

[HttpPost] [AllowAnonymous] public async Task<IActionResult> Register(RegisterViewModel model) { if (!ModelState.IsValid) return View(model);

var user = new ApplicationUser { UserName = model.Email, Email = model.Email, EmailConfirmed = false // Must be false initially };

var result = await _userManager.CreateAsync(user, model.Password); if (!result.Succeeded) { foreach (var error in result.Errors) { ModelState.AddModelError(string.Empty, error.Description); } return View(model); }

// Generate confirmation token var token = await _userManager.GenerateEmailConfirmationTokenAsync(user); var encodedToken = WebUtility.UrlEncode(token); var confirmationUrl = Url.Action( "ConfirmEmail", "Account", new { userId = user.Id, token = encodedToken }, Request.Scheme);

// Send confirmation email await _emailSender.SendEmailAsync( model.Email, "Confirm your email", $"<p>Please confirm your account by <a href='{confirmationUrl}'>clicking here</a>.</p>");

return View("ConfirmationSent", model.Email); }

[HttpGet] [AllowAnonymous] public async Task<IActionResult> ConfirmEmail(string userId, string token) { if (string.IsNullOrEmpty(userId) || string.IsNullOrEmpty(token)) { return View("Error", new { Message = "Invalid confirmation link" }); }

var user = await _userManager.FindByIdAsync(userId); if (user == null) { return View("Error", new { Message = "User not found" }); }

var decodedToken = WebUtility.UrlDecode(token); var result = await _userManager.ConfirmEmailAsync(user, decodedToken);

if (result.Succeeded) { return View("EmailConfirmed"); }

// Show specific errors var errors = string.Join("; ", result.Errors.Select(e => e.Description)); return View("Error", new { Message = $"Confirmation failed: {errors}" }); }

// Resend confirmation email [HttpPost] [AllowAnonymous] public async Task<IActionResult> ResendConfirmationEmail(string email) { var user = await _userManager.FindByEmailAsync(email); if (user == null || user.EmailConfirmed) { // Don't reveal if user exists or is already confirmed return View("ConfirmationSent", email); }

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

await _emailSender.SendEmailAsync( email, "Confirm your email", $"<p>Please confirm your account by <a href='{confirmationUrl}'>clicking here</a>.</p>");

return View("ConfirmationSent", email); } } ```

  1. 1.Handle NotAllowed sign-in result in login action:
  2. 2.```csharp
  3. 3.[HttpPost]
  4. 4.[AllowAnonymous]
  5. 5.[ValidateAntiForgeryToken]
  6. 6.public async Task<IActionResult> Login(LoginViewModel model)
  7. 7.{
  8. 8.if (!ModelState.IsValid) return View(model);

var result = await _signInManager.PasswordSignInAsync( model.Email, model.Password, model.RememberMe, lockoutOnFailure: true);

if (result.Succeeded) { return LocalRedirect(returnUrl); }

if (result.IsNotAllowed) { // Email not confirmed - show helpful message var user = await _userManager.FindByEmailAsync(model.Email); if (user != null) { ModelState.AddModelError(string.Empty, "Please confirm your email address before logging in. " + "<a href='/Account/ResendConfirmation'>Resend confirmation email</a>"); } return View(model); }

if (result.IsLockedOut) { ModelState.AddModelError(string.Empty, "Account locked out. Please try again later."); return View(model); }

ModelState.AddModelError(string.Empty, "Invalid login attempt."); return View(model); } ```

  1. 1.Persist data protection keys to prevent token invalidation:
  2. 2.```csharp
  3. 3.// Program.cs - persist data protection keys
  4. 4.builder.Services.AddDataProtection()
  5. 5..PersistKeysToFileSystem(new DirectoryInfo(@"C:\keys")) // Or Azure Key Vault
  6. 6..SetApplicationName("MyApp") // Same name across deployments
  7. 7..ProtectKeysWithCertificate("thumbprint"); // Optional: encrypt keys

// Without persistent keys, tokens become invalid after app restart // This causes "invalid token" errors on confirmation links ```

Prevention

  • Always register IEmailSender when RequireConfirmedAccount is true
  • URL-encode confirmation tokens before embedding in links
  • Use persistent data protection keys in production environments
  • Provide a resend confirmation email flow for users
  • Log confirmation email send failures for monitoring
  • Set reasonable token lifespan: options.Tokens.EmailConfirmationTokenProvider = "Default";
  • Consider allowing login with unconfirmed email but restricting sensitive actions
  • Test the full registration-to-confirmation flow in integration tests