UserManager<ApplicationUser> and SignInManager<ApplicationUser> are the two primary services for user management and authentication. UserManager handles the “database” side: creating users, setting passwords, assigning roles, generating tokens. SignInManager handles the “session” side: validating credentials, creating the authentication cookie, checking lockout, signing out. Both are registered by AddIdentity and injected through the standard DI system.
Registration and Login
public class AccountController(
UserManager<ApplicationUser> userManager,
SignInManager<ApplicationUser> signInManager,
ILogger<AccountController> logger) : Controller
{
// ── Registration ──────────────────────────────────────────────────────
[HttpGet]
public IActionResult Register() => View(new RegisterViewModel());
[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,
DisplayName = model.DisplayName,
};
// CreateAsync hashes the password and saves to AspNetUsers
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);
}
logger.LogInformation("User {Email} registered.", model.Email);
await signInManager.SignInAsync(user, isPersistent: false);
return RedirectToAction("Index", "Home");
}
// ── Login ─────────────────────────────────────────────────────────────
[HttpGet]
public IActionResult Login(string? returnUrl = null)
{
ViewBag.ReturnUrl = returnUrl;
return View(new LoginViewModel());
}
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> Login(LoginViewModel model, string? returnUrl = null)
{
if (!ModelState.IsValid) return View(model);
var result = await signInManager.PasswordSignInAsync(
model.Email,
model.Password,
isPersistent: model.RememberMe,
lockoutOnFailure: true); // enable lockout protection
if (result.Succeeded)
{
logger.LogInformation("User {Email} logged in.", model.Email);
if (Url.IsLocalUrl(returnUrl))
return Redirect(returnUrl);
return RedirectToAction("Index", "Home");
}
if (result.IsLockedOut)
{
ModelState.AddModelError(string.Empty,
"Account locked due to too many failed attempts. Try again in 5 minutes.");
}
else if (result.IsNotAllowed)
{
ModelState.AddModelError(string.Empty,
"Please confirm your email address before logging in.");
}
else
{
ModelState.AddModelError(string.Empty, "Invalid email or password.");
}
return View(model);
}
// ── Logout ────────────────────────────────────────────────────────────
[HttpPost, ValidateAntiForgeryToken, Authorize]
public async Task<IActionResult> Logout()
{
await signInManager.SignOutAsync();
logger.LogInformation("User signed out.");
return RedirectToAction("Index", "Home");
}
}
Note:
signInManager.PasswordSignInAsync() with lockoutOnFailure: true automatically increments the failed access count on each wrong password and locks the account after reaching MaxFailedAccessAttempts (configured in AddIdentity options). This prevents brute-force attacks without any additional code. The lock duration is DefaultLockoutTimeSpan. Always set lockoutOnFailure: true — passing false disables this protection and makes the account vulnerable to unlimited password guessing.Tip: Display generic error messages on login failure — “Invalid email or password” rather than “Email not found” or “Password incorrect.” Specific messages tell an attacker whether an email address is registered (user enumeration attack). The generic message prevents them from learning which emails have accounts. Similarly, on the registration page, if an email is already taken, redirect to login rather than saying “this email is already registered” — the attacker now knows that account exists.
Warning: Always validate the
returnUrl with Url.IsLocalUrl(returnUrl) before redirecting after login. The login form typically receives returnUrl from the query string: /account/login?returnUrl=/protected-page. An attacker could craft returnUrl=https://evil.com to redirect users to a phishing site after they log in on your site (open redirect attack). The Url.IsLocalUrl() check ensures only local URLs are accepted.Using Authentication in Views and Controllers
// ── In controllers ────────────────────────────────────────────────────────
[Authorize] // any authenticated user
public IActionResult Profile() => View();
[Authorize(Roles = "Admin")] // Admin role only
public IActionResult AdminPanel() => View();
[AllowAnonymous] // override Authorize (global or class-level)
public IActionResult PublicPage() => View();
// Get the current user's ID and details
public async Task<IActionResult> Dashboard()
{
var userId = userManager.GetUserId(User); // ClaimTypes.NameIdentifier value
var user = await userManager.GetUserAsync(User); // full ApplicationUser
return View(user);
}
// ── In views ──────────────────────────────────────────────────────────────
// @if (User.Identity?.IsAuthenticated == true)
// {
// <a asp-controller="Account" asp-action="Logout">
// Hello, @User.Identity.Name!
// </a>
// }
// else
// {
// <a asp-controller="Account" asp-action="Login">Login</a>
// }
Common Mistakes
Mistake 1 — Not using lockoutOnFailure: true (brute force unprotected)
❌ Wrong — unlimited password guessing attempts allowed:
await signInManager.PasswordSignInAsync(email, password, false, lockoutOnFailure: false);
✅ Correct — always set lockoutOnFailure: true.
Mistake 2 — Not validating returnUrl (open redirect vulnerability)
❌ Wrong — redirecting to any returnUrl including external sites.
✅ Correct — always check Url.IsLocalUrl(returnUrl) before using it.