ASP.NET Core Identity is the authentication and authorization system natively included in the .NET ecosystem. If you have ever worked with a web application in ASP.NET Core, you have probably encountered it — but few developers explore its full depth. This article does exactly that.
Basic Architecture
Identity is based on two central services: UserManager<TUser> and SignInManager<TUser>. These are injected through DI and orchestrate almost every user-related operation.
UserManager handles CRUD operations on users: creation, deletion, password change, role management, claims, email confirmation tokens. SignInManager manages the session: password login, external login, 2FA, logout.
Registration in Program.cs looks like this:
builder.Services.AddIdentity<ApplicationUser, IdentityRole>(options =>
{
options.Password.RequiredLength = 8;
options.User.RequireUniqueEmail = true;
options.SignIn.RequireConfirmedEmail = true;
})
.AddEntityFrameworkStores<AppDbContext>()
.AddDefaultTokenProviders();
Custom User — extending IdentityUser
The default IdentityUser model covers the basic fields: email, username, password hash, phone number. But in real applications you need more — full name, avatar, registration date, subscription plan.
The solution is to create a class that inherits from IdentityUser:
public class ApplicationUser : IdentityUser
{
public string FirstName { get; set; } = default!;
public string LastName { get; set; } = default!;
public string? AvatarUrl { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public string PlanId { get; set; } = "basic";
}
Once defined, you replace IdentityUser with ApplicationUser everywhere — in AddIdentity<ApplicationUser, ...>(), in UserManager<ApplicationUser>, and in the EF context.
Custom Password Validators
The default password validators are functional but rigid. If you want custom rules — for example, to forbid passwords containing the username, or to require at least one special character from a specific list — you implement IPasswordValidator<TUser>.
public class NoUsernameInPasswordValidator : IPasswordValidator<ApplicationUser>
{
public Task<IdentityResult> ValidateAsync(
UserManager<ApplicationUser> manager,
ApplicationUser user,
string? password)
{
if (password != null &&
user.UserName != null &&
password.Contains(user.UserName, StringComparison.OrdinalIgnoreCase))
{
return Task.FromResult(IdentityResult.Failed(
new IdentityError
{
Code = "PasswordContainsUsername",
Description = "The password cannot contain the username."
}));
}
return Task.FromResult(IdentityResult.Success);
}
}
Registration in DI:
builder.Services.AddScoped<IPasswordValidator<ApplicationUser>,
NoUsernameInPasswordValidator>();
You can register as many validators as you want — Identity runs them all and aggregates the errors.
Two-Factor Authentication (2FA)
Identity natively supports 2FA via TOTP (Time-based One-Time Password) — compatible with Google Authenticator, Microsoft Authenticator, and any standard TOTP app.
The complete flow involves three steps: user enabling 2FA, generating a QR code with the secret, and verifying the code at each login.
Generating the key and URI for the QR code:
var userId = await _userManager.GetUserIdAsync(user);
var key = await _userManager.GetAuthenticatorKeyAsync(user);
if (string.IsNullOrEmpty(key))
{
await _userManager.ResetAuthenticatorKeyAsync(user);
key = await _userManager.GetAuthenticatorKeyAsync(user);
}
var uri = _urlEncoder.Encode(
$"otpauth://totp/MyApp:{user.Email}?secret={key}&issuer=MyApp");
Verifying the code entered by the user:
var isValid = await _userManager.VerifyTwoFactorTokenAsync(
user,
_userManager.Options.Tokens.AuthenticatorTokenProvider,
code.Replace(" ", "").Replace("-", ""));
if (isValid)
await _userManager.SetTwoFactorEnabledAsync(user, true);
At login, after password validation, SignInManager automatically detects that the user has 2FA enabled and returns SignInResult.TwoFactorRequired, redirecting the flow to the code verification step.
Account Lockout
Lockout protects against brute-force attacks. Identity manages it automatically if you configure it correctly:
builder.Services.AddIdentity<ApplicationUser, IdentityRole>(options =>
{
options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15);
options.Lockout.MaxFailedAccessAttempts = 5;
options.Lockout.AllowedForNewUsers = true;
})
When you call SignInManager.PasswordSignInAsync(..., lockoutOnFailure: true), Identity automatically increments the failed attempt counter. Upon exceeding the threshold, the account is locked for the configured interval.
You can check the lockout status and manage it manually:
// Check
bool isLockedOut = await _userManager.IsLockedOutAsync(user);
// Manual reset (e.g., from admin panel)
await _userManager.ResetAccessFailedCountAsync(user);
await _userManager.SetLockoutEndDateAsync(user, null);
An important detail: GetLockoutEndDateAsync returns a DateTimeOffset? — if it is in the future, the account is locked; if it is null or in the past, the account is active.
External Providers (OAuth 2.0)
Identity integrates natively with external providers through OAuth/OIDC authentication middleware. The most common are Google, Microsoft, GitHub, and Facebook.
Configuration for Google and Microsoft in Program.cs:
builder.Services.AddAuthentication()
.AddGoogle(options =>
{
options.ClientId = configuration["Auth:Google:ClientId"]!;
options.ClientSecret = configuration["Auth:Google:ClientSecret"]!;
})
.AddMicrosoftAccount(options =>
{
options.ClientId = configuration["Auth:Microsoft:ClientId"]!;
options.ClientSecret = configuration["Auth:Microsoft:ClientSecret"]!;
});
The external login flow has two stages. First: redirect to the external provider.
var redirectUrl = Url.Action("ExternalLoginCallback", "Account");
var properties = _signInManager.ConfigureExternalAuthenticationProperties(
provider, redirectUrl);
return Challenge(properties, provider);
Second: processing the callback after authentication at the provider.
var info = await _signInManager.GetExternalLoginInfoAsync();
if (info == null) return RedirectToAction("Login");
var result = await _signInManager.ExternalLoginSignInAsync(
info.LoginProvider, info.ProviderKey, isPersistent: false);
if (!result.Succeeded)
{
// New user — create local account
var email = info.Principal.FindFirstValue(ClaimTypes.Email)!;
var user = new ApplicationUser { UserName = email, Email = email };
await _userManager.CreateAsync(user);
await _userManager.AddLoginAsync(user, info);
await _signInManager.SignInAsync(user, isPersistent: false);
}
Identity automatically stores the link between the local and external account in the AspNetUserLogins table, allowing a user to have multiple providers associated with the same account.
Conclusion
ASP.NET Core Identity is a mature and extensible system — it covers 90% of the authentication needs of a standard web application. Understanding the internal mechanisms (how lockout works, how external claims are processed, how validators chain) makes the difference between a fragile and a solid implementation.
If your project grows and you need enterprise-level federated authentication — multi-tenant, SSO, advanced RBAC — the natural next step is Azure Entra ID or Entra CIAM, which we will explore in a future article.